Merge branch 'stable-2.15'

* stable-2.15:
  Documentation: List all ciphers/MACs available and add some recommendations
  PolyGerrit: Reduce the threshold in gr-watched-projects-editor
  Update polymer to 1.11.0

Change-Id: I3928a1759d3270a0b92dc26e347e07a4b04c0013
diff --git a/Documentation/cmd-review.txt b/Documentation/cmd-review.txt
index 3f6acf6..121fcad 100644
--- a/Documentation/cmd-review.txt
+++ b/Documentation/cmd-review.txt
@@ -124,12 +124,6 @@
 	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.
-
 --tag::
 -t::
   Apply a 'TAG' to the change message, votes, and inline comments. The 'TAG'
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index f0ec02f..abaf314 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -4600,9 +4600,8 @@
 [[upload]]
 === Section upload
 
-Sets the group of users allowed to execute 'upload-pack' on the
-server, 'upload-pack' is what runs on the server during a user's
-fetch, clone or repo sync command.
+Options to control the behavior of `upload-pack` on the server side,
+which handles a user's fetch, clone, or repo sync command.
 
 ----
 [upload]
@@ -4612,8 +4611,8 @@
 
 [[upload.allowGroup]]upload.allowGroup::
 +
-Name of the groups of users that are allowed to execute 'upload-pack'
-on the server. One or more groups can be set.
+Name of the groups of users that are allowed to execute 'upload-pack'.
+One or more groups can be set.
 +
 If no groups are added, any user will be allowed to execute
 'upload-pack' on the server.
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index af2bd98..3b810e0 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -7,8 +7,9 @@
 them and easily modify them to tweak their contents.
 
 *Compatibility Note:* previously, Velocity Template Language (VTL) was used as
-the template language for Gerrit emails. VTL has now been deprecated in favor of
-Soy, but Velocity templates that modify text emails remain supported for now.
+the template language for Gerrit emails. Support for VTL has now been removed
+in favor of Soy, and Velocity templates that modify text emails are no longer
+supported.
 
 == Template Locations and Extensions:
 
@@ -231,6 +232,14 @@
 +
 The refname of the patch set.
 
+$patchSetInfo.authorName::
++
+The name of the author of the patch set.
+
+$patchSetInfo.authorEmail::
++
+The email address of the author of the patch set.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index dc81f44..ad2f947 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -358,25 +358,48 @@
 `lib/jgit/jgit.bzl` setting LOCAL_JGIT_REPO to a directory holding a
 JGit repository.
 
-[[clean-cache]]
+[[clean-download-cache]]
 === Cleaning The download cache
 
-The cache for the Gerrit Code Review project is located in
-`~/.gerritcodereview/buck-cache/locally-built-artifacts`.
+The cache for downloaded artifacts is located in
+`~/.gerritcodereview/buck-cache/downloaded-artifacts`.
 
-If you really do need to clean the cache manually, then:
+If you really do need to clean the download cache manually, then:
 
 ----
- rm -rf ~/.gerritcodereview/buck-cache/locally-built-artifacts
+ rm -rf ~/.gerritcodereview/buck-cache/downloaded-artifacts
 ----
 
-Note that the root `buck-cache` folder should not be deleted as it also contains
-the `downloaded-artifacts` directory, which holds the artifacts that got
-downloaded (not built locally).
-
 [NOTE] When building with Bazel the artifacts are still cached in
-`~/.gerritcodereview/buck-cache/`. This allows Bazel to make use of
-libraries that were previously downloaded by Buck.
+`~/.gerritcodereview/buck-cache/downloaded-artifacts`. This allows Bazel to
+make use of libraries that were previously downloaded by Buck.
+
+[[local-action-cache]]
+
+To accelerate builds, local action cache can be activated. Note, that this
+experimental feature is not activated per default and only available since
+Bazel version 0.7.
+
+To activate the local action cache, create accessible cache directory:
+
+----
+ mkdir -p ~/.gerritcodereview/bazel-cache/cas
+----
+
+and add these lines to your `~/.bazelrc` file:
+
+----
+build --experimental_local_disk_cache_path=/home/<user>/.gerritcodereview/bazel-cache/cas
+build --experimental_local_disk_cache
+build --experimental_strict_action_env
+test --experimental_local_disk_cache_path=/home/user>/.gerritcodereview/bazel-cache/cas
+test --experimental_local_disk_cache
+test --experimental_strict_action_env
+----
+
+[NOTE] `experimental_local_disk_cache_path` must be absolute path. Expansion of `~` is
+unfortunately not supported yet. This is also the reason why we can't activate this
+feature by default yet (by adjusting tools/bazel.rc file).
 
 GERRIT
 ------
diff --git a/Documentation/dev-plugin-pg-styling.txt b/Documentation/dev-plugin-pg-styling.txt
deleted file mode 100644
index 618d984..0000000
--- a/Documentation/dev-plugin-pg-styling.txt
+++ /dev/null
@@ -1,61 +0,0 @@
-= Gerrit Code Review - PolyGerrit Plugin Styling
-
-CAUTION: Work in progress. Hard hat area. +
-This document will be populated with details along with implementation. +
-link:https://groups.google.com/d/topic/repo-discuss/vb8WJ4m0hK0/discussion[Join the discussion.]
-
-== 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:dev-plugins-pg.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/dev-plugins.txt b/Documentation/dev-plugins.txt
index 1a026d1..f7028e0 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -407,6 +407,10 @@
 +
 Update of the group secondary index
 
+* `com.google.gerrit.server.extensions.events.ProjectIndexedListener`:
++
+Update of the project secondary index
+
 * `com.google.gerrit.httpd.WebLoginListener`:
 +
 User login or logout interactively on the Web user interface.
@@ -479,6 +483,15 @@
 submitted by Rebase Always and Cherry Pick submit strategies as well as
 change being queried with COMMIT_FOOTERS option.
 
+[[merge-super-set-computation]]
+== Merge Super Set Computation
+
+The algorithm to compute the merge super set to detect changes that
+should be submitted together can be customized by implementing
+`com.google.gerrit.server.git.MergeSuperSetComputation`.
+MergeSuperSetComputation is a DynamicItem, so Gerrit may only have one
+implementation.
+
 [[receive-pack]]
 == Receive Pack Initializers
 
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 039d545..53ded48 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -73,47 +73,32 @@
 
 == Create the Actual Release
 
-To create a Gerrit release the following steps have to be done:
-
-. 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]
-.. link:#publish-to-google-storage[Publish the Gerrit WAR to Google Storage]
-.. link:#push-stable[Push the Stable Branch]
-.. link:#push-tag[Push the Release Tag]
-.. link:#upload-documentation[Upload the Documentation]
-.. link:#finalize-release-notes[Finalize Release Notes]
-.. link:#update-issues[Update the Issues]
-.. link:#announce[Announce on Mailing List]
-. link:#increase-version[Increase Gerrit Version for Current Development]
-. link:#merge-stable[Merge `stable` into `master`]
-
-
 [[update-versions]]
 === Update Versions and Create Release Tag
 
 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`.
+file must be updated, e.g. change it from `$version-SNAPSHOT` to `$version`.
 
-In addition the version must be updated in a number of pom.xml files.
+In addition the version must be updated in a number of `*_pom.xml` files.
 
 To do this run the `./tools/version.py` script and provide the new
 version as parameter, e.g.:
 
 ----
-  ./tools/version.py 2.5
+  version=2.15
+  ./tools/version.py $version
 ----
 
 Commit the changes and create a signed release tag on the new commit:
 
 ----
-  git tag -s -m "v2.5" v2.5
+  git tag -s -m "v$version" "v$version"
 ----
 
 Tag the plugins:
 
 ----
-  git submodule foreach git tag -s -m "v2.5" v2.5
+  git submodule foreach git tag -s -m "v$version" "v$version"
 ----
 
 [[build-gerrit]]
@@ -126,8 +111,12 @@
   ./tools/maven/api.sh install
 ----
 
-* Sanity check WAR
-* Test the new Gerrit version
+* Verify the WAR version:
++
+----
+  java -jar ~/dl/gerrit-$version.war --version
+----
+* Try upgrading a test site and launching the daemon
 
 * Verify plugin versions
 +
@@ -148,7 +137,7 @@
 configuration] for deploying to Maven Central
 
 * Make sure that the version is updated in the `version.bzl` file and in
-the `pom.xml` files as described in the link:#update-versions[Update
+the `*_pom.xml` files as described in the link:#update-versions[Update
 Versions and Create Release Tag] section.
 
 * Push the WAR to Maven Central:
@@ -203,7 +192,7 @@
 +
 Use this URL for further testing of the artifacts in this repository,
 e.g. to try building a plugin against the plugin API in this repository
-update the version in the `pom.xml` and configure the repository:
+update the version in the `*_pom.xml` and configure the repository:
 +
 ----
   <repositories>
@@ -257,11 +246,11 @@
 [[push-stable]]
 ==== Push the Stable Branch
 
-* Create the stable branch `stable-2.5` in the `gerrit` project via the
+* Create the stable branch `stable-$version` in the `gerrit` project via the
 link:https://gerrit-review.googlesource.com/#/admin/projects/gerrit,branches[
 Gerrit Web UI] or by push.
 
-* Push the commits done on `stable-2.5` to `refs/for/stable-2.5` and
+* Push the commits done on `stable-$version` to `refs/for/stable-$version` and
 get them merged
 
 
@@ -271,13 +260,13 @@
 Push the new Release Tag:
 
 ----
-  git push gerrit-review tag v2.5
+  git push gerrit-review tag v$version
 ----
 
 Push the new Release Tag on the plugins:
 
 ----
-  git submodule foreach git push gerrit-review tag v2.5
+  git submodule foreach git push gerrit-review tag v$version
 ----
 
 
@@ -314,11 +303,11 @@
 Update the issues by hand. There is no script for this.
 
 Our current process is an issue should be updated to say `Status =
-Submitted, FixedIn-2.5` once the change is submitted, but before the
+Submitted, FixedIn-$version` once the change is submitted, but before the
 release.
 
 After the release is actually made, you can search in Google Code for
-`Status=Submitted FixedIn=2.5` and then batch update these changes
+`Status=Submitted FixedIn=$version` and then batch update these changes
 to say `Status=Released`. Make sure the pulldown says `All Issues`
 because `Status=Submitted` is considered a closed issue.
 
diff --git a/Documentation/dev-plugins-pg.txt b/Documentation/pg-plugin-dev.txt
similarity index 66%
rename from Documentation/dev-plugins-pg.txt
rename to Documentation/pg-plugin-dev.txt
index e1bf39e..92c52f6 100644
--- a/Documentation/dev-plugins-pg.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -1,10 +1,5 @@
 = Gerrit Code Review - PolyGerrit Plugin Development
 
-CAUTION: Work in progress. Hard hat area. +
-This document will be populated with details along with implementation. +
-link:https://groups.google.com/d/topic/repo-discuss/vb8WJ4m0hK0/discussion[Join
-the discussion.]
-
 [[loading]]
 == Plugin loading and initialization
 
@@ -33,8 +28,8 @@
 </dom-module>
 ```
 
-[[low-level-api]]
-== Low-level DOM API
+[[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
@@ -126,3 +121,119 @@
   </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-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/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 50afe40..638d793 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -5201,6 +5201,9 @@
 
 Cherry picks a revision to a destination branch.
 
+To cherry pick a commit with no change-id associated with it, see
+link:rest-api-projects.html#cherry-pick-commit[CherryPickCommit].
+
 The commit message and destination branch must be provided in the request body inside a
 link:#cherrypick-input[CherryPickInput] entity.  If the commit message
 does not specify a Change-Id, a new one is picked for the destination change.
@@ -6693,13 +6696,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
diff --git a/Documentation/rest-api-groups.txt b/Documentation/rest-api-groups.txt
index 7eac992..45c5e34 100644
--- a/Documentation/rest-api-groups.txt
+++ b/Documentation/rest-api-groups.txt
@@ -124,6 +124,40 @@
 * `MEMBERS`: include list of direct group members.
 --
 
+==== Find groups that are owned by another group
+
+By setting `ownedBy` and specifying the link:#group-id[\{group-id\}] of another
+group, it is possible to find all the groups for which the owning group is the
+given group.
+
+.Request
+----
+  GET /groups/?ownedBy=7ca042f4d5847936fcb90ca91057673157fd06fc HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "MyProject-Committers": {
+      "id": "9999c971bb4ab872aab759d8c49833ee6b9ff320",
+      "url": "#/admin/groups/uuid-9999c971bb4ab872aab759d8c49833ee6b9ff320",
+      "options": {
+        "visible_to_all": true
+      },
+      "description":"contains all committers for MyProject",
+      "group_id": 551,
+      "owner": "MyProject-Owners",
+      "owner_id": "7ca042f4d5847936fcb90ca91057673157fd06fc",
+      "created_on": "2013-02-01 09:59:32.126000000"
+    }
+  }
+----
+
 ==== Check if a group is owned by the calling user
 By setting the option `owned` and specifying a group to inspect with
 the option `group`/`g`, it is possible to find out if this group is
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 9d76d34..a7c26b8 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -324,6 +324,65 @@
   }
 ----
 
+[[query-projects]]
+=== Query Projects
+--
+'GET /projects/?query=<query>'
+--
+
+Queries projects visible to the caller. The
+link:user-search-projects.html#_search_operators[query string] must be
+provided by the `query` parameter. The `start` and `limit` parameters
+can be used to skip/limit results.
+
+As result a list of link:#project-info[ProjectInfo] entities is returned.
+
+.Request
+----
+  GET /projects/?query=name:test HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "test": {
+      "id": "test",
+      "description": "\u003chtml\u003e is escaped"
+    }
+  }
+----
+
+[[project-query-limit]]
+==== Project Limit
+The `/projects/?query=<query>` URL also accepts a limit integer in the
+`limit` parameter. This limits the results to `limit` projects.
+
+Query the first 25 projects in project list.
+----
+  GET /projects/?query=<query>&limit=25 HTTP/1.0
+----
+
+The `/projects/` URL also accepts a start integer in the `start`
+parameter. The results will skip `start` groups from project list.
+
+Query 25 projects starting from index 50.
+----
+  GET /groups/?query=<query>&limit=25&start=50 HTTP/1.0
+----
+
+[[project-query-options]]
+==== Project Options
+Additional fields can be obtained by adding `o` parameters. Each option
+requires more lookups and slows down the query response time to the
+client so they are generally disabled by default. The supported fields
+are described in the context of the link:#project-options[List Projects]
+REST endpoint.
+
 [[get-project]]
 === Get Project
 --
@@ -2306,6 +2365,8 @@
 
 Cherry-picks a commit of a project to a destination branch.
 
+To cherry pick a change revision, see link:rest-api-changes.html#cherry-pick[CherryPick].
+
 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
diff --git a/Documentation/user-search-projects.txt b/Documentation/user-search-projects.txt
new file mode 100644
index 0000000..ba20adb
--- /dev/null
+++ b/Documentation/user-search-projects.txt
@@ -0,0 +1,48 @@
+= Gerrit Code Review - Searching Projects
+
+[[search-operators]]
+== Search Operators
+
+Operators act as restrictions on the search. As more operators
+are added to the same query string, they further restrict the
+returned results.
+
+[[name]]
+name:'NAME'::
++
+Matches projects that have exactly the name 'NAME'.
+
+[[inname]]
+inname:'NAME'::
++
+Matches projects that a name part that starts with 'NAME' (case
+insensitive).
+
+[[description]]
+description:'DESCRIPTION'::
++
+Matches projects whose description contains 'DESCRIPTION', using a
+full-text search.
+
+== Magical Operators
+
+[[is-visible]]
+is:visible::
++
+Magical internal flag to prove the current user has access to read
+the projects and all the refs. This flag is always added to any query.
+
+[[limit]]
+limit:'CNT'::
++
+Limit the returned results to no more than 'CNT' records. This is
+automatically set to the page size configured in the current user's
+preferences. Including it in a web query may lead to unpredictable
+results with regards to pagination.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/WORKSPACE b/WORKSPACE
index ad1bc8a..2857854 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,5 +1,9 @@
 workspace(name = "gerrit")
 
+load("//:version.bzl", "check_version")
+
+check_version("0.5.3")
+
 load("//tools/bzl:maven_jar.bzl", "maven_jar", "GERRIT", "MAVEN_LOCAL")
 load("//lib/codemirror:cm.bzl", "CM_VERSION", "DIFF_MATCH_PATCH_VERSION")
 load("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
@@ -175,8 +179,8 @@
 
 maven_jar(
     name = "gson",
-    artifact = "com.google.code.gson:gson:2.8.0",
-    sha1 = "c4ba5371a29ac9b2ad6129b1d39ea38750043eff",
+    artifact = "com.google.code.gson:gson:2.8.2",
+    sha1 = "3edcfe49d2c6053a70a2a47e4e1c2f94998a49cf",
 )
 
 maven_jar(
@@ -188,20 +192,8 @@
 
 maven_jar(
     name = "protobuf",
-    artifact = "com.google.protobuf:protobuf-java:3.0.0-beta-2",
-    sha1 = "de80fe047052445869b96f6def6baca7182c95af",
-)
-
-maven_jar(
-    name = "joda_time",
-    artifact = "joda-time:joda-time:2.9.9",
-    sha1 = "f7b520c458572890807d143670c9b24f4de90897",
-)
-
-maven_jar(
-    name = "joda_convert",
-    artifact = "org.joda:joda-convert:1.8.1",
-    sha1 = "675642ac208e0b741bc9118dcbcae44c271b992a",
+    artifact = "com.google.protobuf:protobuf-java:3.4.0",
+    sha1 = "b32aba0cbe737a4ca953f71688725972e3ee927c",
 )
 
 load("//lib:guava.bzl", "GUAVA_VERSION", "GUAVA_BIN_SHA1")
@@ -213,12 +205,6 @@
 )
 
 maven_jar(
-    name = "velocity",
-    artifact = "org.apache.velocity:velocity:1.7",
-    sha1 = "2ceb567b8f3f21118ecdec129fe1271dbc09aa7a",
-)
-
-maven_jar(
     name = "jsch",
     artifact = "com.jcraft:jsch:0.1.54",
     sha1 = "da3584329a263616e277e15462b387addd1b208d",
@@ -364,12 +350,12 @@
     sha1 = "2e35862b0435c1b027a21f3d6eecbe50e6e08d54",
 )
 
-GREENMAIL_VERS = "1.5.3"
+GREENMAIL_VERS = "1.5.5"
 
 maven_jar(
     name = "greenmail",
     artifact = "com.icegreen:greenmail:" + GREENMAIL_VERS,
-    sha1 = "afabf8178312f7f220f74f1558e457bf54fa4253",
+    sha1 = "9ea96384ad2cb8118c22f493b529eb72c212691c",
 )
 
 MAIL_VERS = "1.5.6"
@@ -440,8 +426,8 @@
 
 maven_jar(
     name = "tukaani_xz",
-    artifact = "org.tukaani:xz:1.4",
-    sha1 = "18a9a2ce6abf32ea1b5fd31dae5210ad93f4e5e3",
+    artifact = "org.tukaani:xz:1.6",
+    sha1 = "05b6f921f1810bdf90e25471968f741f87168b64",
 )
 
 # When upgrading Lucene, make sure it's compatible with Elasticsearch
@@ -591,8 +577,8 @@
 # Keep this version of Soy synchronized with the version used in Gitiles.
 maven_jar(
     name = "soy",
-    artifact = "com.google.template:soy:2017-04-23",
-    sha1 = "52f32a5a3801ab97e0909373ef7f73a3460d0802",
+    artifact = "com.google.template:soy:2017-08-08",
+    sha1 = "792aa49e3ec3f61e793e56b499f0724df1c1e16c",
 )
 
 maven_jar(
@@ -609,8 +595,8 @@
 
 maven_jar(
     name = "dropwizard_core",
-    artifact = "io.dropwizard.metrics:metrics-core:3.2.4",
-    sha1 = "36af4975e38bb39686a63ba5139dce8d3f410669",
+    artifact = "io.dropwizard.metrics:metrics-core:3.2.5",
+    sha1 = "ea2316646e9787c5b2d14ca97f4ef7ad5c6b94e9",
 )
 
 # When updading Bouncy Castle, also update it in bazlets.
@@ -717,18 +703,18 @@
     sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
 )
 
-TRUTH_VERS = "0.35"
+TRUTH_VERS = "0.36"
 
 maven_jar(
     name = "truth",
     artifact = "com.google.truth:truth:" + TRUTH_VERS,
-    sha1 = "c08a7fde45e058323bcfa3f510d4fe1e2b028f37",
+    sha1 = "7485219d2c1d341097a19382c02bde07e69ff5d2",
 )
 
 maven_jar(
     name = "truth-java8-extension",
     artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-    sha1 = "5457fdf91b1e954b070ad7f2db9bea5505da4bca",
+    sha1 = "dcc60988c8f9a051840766ef192a2ef41e7992f1",
 )
 
 # When bumping the easymock version number, make sure to also move powermock to a compatible version
@@ -922,8 +908,8 @@
 # When upgrading Elasticsearch, make sure it's compatible with Lucene
 maven_jar(
     name = "elasticsearch",
-    artifact = "org.elasticsearch:elasticsearch:2.4.5",
-    sha1 = "daafe48ae06592029a2fedca1fe2ac0f5eec3185",
+    artifact = "org.elasticsearch:elasticsearch:2.4.6",
+    sha1 = "d2954e1173a608a9711f132d1768a676a8b1fb81",
 )
 
 # Java REST client for Elasticsearch.
@@ -942,6 +928,18 @@
 )
 
 maven_jar(
+    name = "joda_time",
+    artifact = "joda-time:joda-time:2.9.9",
+    sha1 = "f7b520c458572890807d143670c9b24f4de90897",
+)
+
+maven_jar(
+    name = "joda_convert",
+    artifact = "org.joda:joda-convert:1.8.1",
+    sha1 = "675642ac208e0b741bc9118dcbcae44c271b992a",
+)
+
+maven_jar(
     name = "compress_lzf",
     artifact = "com.ning:compress-lzf:1.0.2",
     sha1 = "62896e6fca184c79cc01a14d143f3ae2b4f4b4ae",
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 958eeb9..adcb57e 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,8 @@
 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;
@@ -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;
@@ -1007,7 +1012,6 @@
         gApi.changes().id(chId).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
 
     assertThat(info.nonVisibleChanges).isEqualTo(0);
-    assertThat(actual).hasSize(expected.length);
     assertThat(changeIds(actual)).containsExactly((Object[]) expected).inOrder();
     assertThat(changeIds(info.changes)).containsExactly((Object[]) expected).inOrder();
   }
@@ -1383,4 +1387,19 @@
     return rw.parseCommit(
         ObjectId.fromString(get(changeId, ListChangesOption.CURRENT_REVISION).currentRevision));
   }
+
+  protected void configLabel(String label, LabelFunction func) throws Exception {
+    configLabel(
+        project, label, func, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+  }
+
+  protected void configLabel(
+      Project.NameKey project, String label, LabelFunction func, LabelValue... value)
+      throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType labelType = category(label, value);
+    labelType.setFunction(func);
+    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
index b2e7415..decc471 100644
--- 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
@@ -21,9 +21,8 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
-import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.FailureMetadata;
 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;
@@ -62,18 +61,8 @@
     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);
+    return assertAbout(FakeEmailSenderSubject::new).that(sender);
   }
 
   protected void setEmailStrategy(TestAccount account, EmailStrategy strategy) throws Exception {
@@ -98,8 +87,8 @@
     private Map<RecipientType, List<String>> recipients = new HashMap<>();
     private Set<String> accountedFor = new HashSet<>();
 
-    FakeEmailSenderSubject(FailureStrategy failureStrategy, FakeEmailSender target) {
-      super(failureStrategy, target);
+    FakeEmailSenderSubject(FailureMetadata failureMetadata, FakeEmailSender target) {
+      super(failureMetadata, target);
     }
 
     public FakeEmailSenderSubject notSent() {
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 c9a474f..e11651f 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
@@ -196,7 +196,7 @@
     PushCommand pushCmd = testRepo.git().push();
     pushCmd.setForce(force);
     pushCmd.setPushOptions(pushOptions);
-    pushCmd.setRefSpecs(new RefSpec(source + ":" + target));
+    pushCmd.setRefSpecs(new RefSpec((source != null ? source : "") + ":" + target));
     if (pushTags) {
       pushCmd.setPushTags();
     }
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 e2e29c9..629c6bd 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
@@ -39,9 +39,7 @@
 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;
@@ -286,7 +284,7 @@
 
   private static class Receive implements ReceivePackFactory<Context> {
     private final Provider<CurrentUser> userProvider;
-    private final ProjectControl.GenericFactory projectControlFactory;
+    private final ProjectCache projectCache;
     private final AsyncReceiveCommits.Factory factory;
     private final TransferConfig config;
     private final DynamicSet<ReceivePackInitializer> receivePackInitializers;
@@ -297,7 +295,7 @@
     @Inject
     Receive(
         Provider<CurrentUser> userProvider,
-        ProjectControl.GenericFactory projectControlFactory,
+        ProjectCache projectCache,
         AsyncReceiveCommits.Factory factory,
         TransferConfig config,
         DynamicSet<ReceivePackInitializer> receivePackInitializers,
@@ -305,7 +303,7 @@
         ThreadLocalRequestContext threadContext,
         PermissionBackend permissionBackend) {
       this.userProvider = userProvider;
-      this.projectControlFactory = projectControlFactory;
+      this.projectCache = projectCache;
       this.factory = factory;
       this.config = config;
       this.receivePackInitializers = receivePackInitializers;
@@ -333,8 +331,14 @@
         throw new RuntimeException(e);
       }
       try {
-        ProjectControl ctl = projectControlFactory.controlFor(req.project, userProvider.get());
-        AsyncReceiveCommits arc = factory.create(ctl, db, null, ImmutableSetMultimap.of());
+        IdentifiedUser identifiedUser = userProvider.get().asIdentifiedUser();
+        ProjectState projectState = projectCache.checkedGet(req.project);
+        if (projectState == null) {
+          throw new RuntimeException(String.format("project %s not found", req.project));
+        }
+
+        AsyncReceiveCommits arc =
+            factory.create(projectState, identifiedUser, db, null, ImmutableSetMultimap.of());
         ReceivePack rp = arc.getReceivePack();
 
         Capable r = arc.canUpload();
@@ -342,17 +346,17 @@
           throw new ServiceNotAuthorizedException();
         }
 
-        rp.setRefLogIdent(ctl.getUser().asIdentifiedUser().newRefLogIdent());
+        rp.setRefLogIdent(identifiedUser.newRefLogIdent());
         rp.setTimeout(config.getTimeout());
         rp.setMaxObjectSizeLimit(config.getMaxObjectSizeLimit());
 
         for (ReceivePackInitializer initializer : receivePackInitializers) {
-          initializer.init(ctl.getProject().getNameKey(), rp);
+          initializer.init(projectState.getNameKey(), rp);
         }
 
         rp.setPostReceiveHook(PostReceiveHookChain.newChain(Lists.newArrayList(postReceiveHooks)));
         return rp;
-      } catch (NoSuchProjectException | IOException e) {
+      } catch (IOException | PermissionBackendException e) {
         throw new RuntimeException(e);
       }
     }
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 baa0a68..7ca424d 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
@@ -69,6 +69,7 @@
 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;
@@ -132,6 +133,7 @@
 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.Util;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -1582,7 +1584,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);
@@ -3189,6 +3190,68 @@
     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();
   }
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 305a2b0..2118f29 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
@@ -491,6 +491,33 @@
   }
 
   @Test
+  public void getGroupsByOwner() throws Exception {
+    String parent = createGroup("test-parent");
+    List<String> children =
+        Arrays.asList(createGroup("test-child1", parent), createGroup("test-child2", parent));
+
+    // By UUID
+    List<GroupInfo> owned =
+        gApi.groups().list().withOwnedBy(getFromCache(parent).getGroupUUID().get()).get();
+    assertThat(owned.stream().map(g -> g.name).collect(toList()))
+        .containsExactlyElementsIn(children);
+
+    // By name
+    owned = gApi.groups().list().withOwnedBy(parent).get();
+    assertThat(owned.stream().map(g -> g.name).collect(toList()))
+        .containsExactlyElementsIn(children);
+
+    // By group that does not own any others
+    owned = gApi.groups().list().withOwnedBy(owned.get(0).id).get();
+    assertThat(owned).isEmpty();
+
+    // By non-existing group
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Group Not Found: does-not-exist");
+    gApi.groups().list().withOwnedBy("does-not-exist").get();
+  }
+
+  @Test
   public void onlyVisibleGroupsReturned() throws Exception {
     String newGroupName = name("newGroup");
     GroupInput in = new GroupInput();
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
index 0fa09af..9b12069f 100644
--- 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
@@ -28,6 +28,7 @@
 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.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RawInput;
@@ -45,7 +46,7 @@
   private static final RawInput HTML_PLUGIN_CONTENT =
       RawInputUtil.create(HTML_PLUGIN.getBytes(UTF_8));
 
-  private static final List<String> PLUGINS =
+  private static final ImmutableList<String> PLUGINS =
       ImmutableList.of(
           "plugin-a.js", "plugin-b.html", "plugin-c.js", "plugin-d.html", "plugin_e.js");
 
@@ -107,12 +108,21 @@
     api = gApi.plugins().name("plugin-a");
     assertThat(api.get().disabled).isNull();
     assertPlugins(list().get(), PLUGINS);
+
+    // Non-admin cannot disable
+    setApiUser(user);
+    try {
+      gApi.plugins().name("plugin-a").disable();
+      fail("Expected AuthException");
+    } catch (AuthException expected) {
+      // Expected
+    }
   }
 
   @Test
   public void installNotAllowed() throws Exception {
     exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("remote installation is disabled");
+    exception.expectMessage("remote plugin administration is disabled");
     gApi.plugins().install("test.js", new InstallPluginInput());
   }
 
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
index b140a6e..6f4495e 100644
--- 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
@@ -16,12 +16,15 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 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.DashboardSectionInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -56,20 +59,40 @@
 
   @Test
   public void getDashboard() throws Exception {
-    assertThat(dashboards()).isEmpty();
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    DashboardInfo info = createTestDashboard();
     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);
+    assertDashboardInfo(result, info);
+  }
+
+  @Test
+  public void getDashboardWithNoDescription() throws Exception {
+    DashboardInfo info = newDashboardInfo(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    info.description = null;
+    DashboardInfo created = createDashboard(info);
+    assertThat(created.description).isNull();
+    DashboardInfo result = project().dashboard(created.id).get();
+    assertThat(result.description).isNull();
+  }
+
+  @Test
+  public void getDashboardNonDefault() throws Exception {
+    DashboardInfo info = createTestDashboard("my", "test");
+    DashboardInfo result = project().dashboard(info.id).get();
+    assertDashboardInfo(result, info);
+  }
+
+  @Test
+  public void listDashboards() throws Exception {
+    assertThat(dashboards()).isEmpty();
+    DashboardInfo info1 = createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test1");
+    DashboardInfo info2 = createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test2");
+    assertThat(dashboards().stream().map(d -> d.id).collect(toList()))
+        .containsExactly(info1.id, info2.id);
   }
 
   @Test
   public void setDefaultDashboard() throws Exception {
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    DashboardInfo info = createTestDashboard();
     assertThat(info.isDefault).isNull();
     project().dashboard(info.id).setDefault();
     assertThat(project().dashboard(info.id).get().isDefault).isTrue();
@@ -78,7 +101,7 @@
 
   @Test
   public void setDefaultDashboardByProject() throws Exception {
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    DashboardInfo info = createTestDashboard();
     assertThat(info.isDefault).isNull();
     project().defaultDashboard(info.id);
     assertThat(project().dashboard(info.id).get().isDefault).isTrue();
@@ -93,8 +116,8 @@
 
   @Test
   public void replaceDefaultDashboard() throws Exception {
-    DashboardInfo d1 = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test1");
-    DashboardInfo d2 = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test2");
+    DashboardInfo d1 = createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test1");
+    DashboardInfo d2 = createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test2");
     assertThat(d1.isDefault).isNull();
     assertThat(d2.isDefault).isNull();
     project().dashboard(d1.id).setDefault();
@@ -109,12 +132,28 @@
 
   @Test
   public void cannotGetDashboardWithInheritedForNonDefault() throws Exception {
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    DashboardInfo info = createTestDashboard();
     exception.expect(BadRequestException.class);
     exception.expectMessage("inherited flag can only be used with default");
     project().dashboard(info.id).get(true);
   }
 
+  private void assertDashboardInfo(DashboardInfo actual, DashboardInfo expected) throws Exception {
+    assertThat(actual.id).isEqualTo(expected.id);
+    assertThat(actual.path).isEqualTo(expected.path);
+    assertThat(actual.ref).isEqualTo(expected.ref);
+    assertThat(actual.project).isEqualTo(project.get());
+    assertThat(actual.definingProject).isEqualTo(project.get());
+    assertThat(actual.description).isEqualTo(expected.description);
+    assertThat(actual.title).isEqualTo(expected.title);
+    assertThat(actual.foreach).isEqualTo(expected.foreach);
+    if (expected.sections == null) {
+      assertThat(actual.sections).isNull();
+    } else {
+      assertThat(actual.sections.size()).isEqualTo(expected.sections.size());
+    }
+  }
+
   private List<DashboardInfo> dashboards() throws Exception {
     return project().dashboards().get();
   }
@@ -123,8 +162,27 @@
     return gApi.projects().name(project.get());
   }
 
-  private DashboardInfo createDashboard(String ref, String path) throws Exception {
+  private DashboardInfo newDashboardInfo(String ref, String path) {
     DashboardInfo info = DashboardsCollection.newDashboardInfo(ref, path);
+    info.title = "Reviewer";
+    info.description = "Own review requests";
+    info.foreach = "owner:self";
+    DashboardSectionInfo section = new DashboardSectionInfo();
+    section.name = "Open";
+    section.query = "is:open";
+    info.sections = ImmutableList.of(section);
+    return info;
+  }
+
+  private DashboardInfo createTestDashboard() throws Exception {
+    return createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+  }
+
+  private DashboardInfo createTestDashboard(String ref, String path) throws Exception {
+    return createDashboard(newDashboardInfo(ref, path));
+  }
+
+  private DashboardInfo createDashboard(DashboardInfo info) throws Exception {
     String canonicalRef = DashboardsCollection.normalizeDashboardRef(info.ref);
     try {
       project().branch(canonicalRef).create(new BranchInput());
@@ -137,13 +195,23 @@
     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);
+      StringBuilder content = new StringBuilder("[dashboard]\n");
+      if (info.title != null) {
+        content.append("title = ").append(info.title).append("\n");
+      }
+      if (info.description != null) {
+        content.append("description = ").append(info.description).append("\n");
+      }
+      if (info.foreach != null) {
+        content.append("foreach = ").append(info.foreach).append("\n");
+      }
+      if (info.sections != null) {
+        for (DashboardSectionInfo section : info.sections) {
+          content.append("[section \"").append(section.name).append("\"]\n");
+          content.append("query = ").append(section.query).append("\n");
+        }
+      }
+      cb.add(info.path, content.toString());
       RevCommit c = cb.create();
       project().commit(c.name());
     }
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 94dcf31..aba5c7d 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
@@ -15,10 +15,14 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static java.util.stream.Collectors.toSet;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
@@ -28,31 +32,82 @@
 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.events.ProjectIndexedListener;
+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;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class ProjectIT extends AbstractDaemonTest {
+  @Inject private DynamicSet<ProjectIndexedListener> projectIndexedListeners;
+
+  private ProjectIndexedCounter projectIndexedCounter;
+  private RegistrationHandle projectIndexedCounterHandle;
+
+  @Before
+  public void addProjectIndexedCounter() {
+    projectIndexedCounter = new ProjectIndexedCounter();
+    projectIndexedCounterHandle = projectIndexedListeners.add(projectIndexedCounter);
+  }
+
+  @After
+  public void removeProjectIndexedCounter() {
+    if (projectIndexedCounterHandle != null) {
+      projectIndexedCounterHandle.remove();
+    }
+  }
 
   @Test
   public void createProject() throws Exception {
     String name = name("foo");
-    assertThat(name).isEqualTo(gApi.projects().create(name).get().name);
+    assertThat(gApi.projects().create(name).get().name).isEqualTo(name);
 
     RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
     eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
 
     eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master", new String[] {});
+    projectIndexedCounter.assertReindexOf(name);
+  }
+
+  @Test
+  public void createProjectWithInitialBranches() throws Exception {
+    String name = name("foo");
+    ProjectInput input = new ProjectInput();
+    input.name = name;
+    input.createEmptyCommit = true;
+    input.branches = ImmutableList.of("master", "foo");
+    assertThat(gApi.projects().create(input).get().name).isEqualTo(name);
+    assertThat(
+            gApi.projects().name(name).branches().get().stream().map(b -> b.ref).collect(toSet()))
+        .containsExactly("refs/heads/foo", "refs/heads/master", "HEAD", RefNames.REFS_CONFIG);
+
+    RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
+
+    head = getRemoteHead(name, "refs/heads/foo");
+    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/foo", null, head);
+
+    head = getRemoteHead(name, "refs/heads/master");
+    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master", null, head);
+
+    projectIndexedCounter.assertReindexOf(name);
   }
 
   @Test
   public void createProjectWithGitSuffix() throws Exception {
     String name = name("foo");
-    assertThat(name).isEqualTo(gApi.projects().create(name + ".git").get().name);
+    assertThat(gApi.projects().create(name + ".git").get().name).isEqualTo(name);
 
     RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
     eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
@@ -66,7 +121,7 @@
     ProjectInput input = new ProjectInput();
     input.name = name;
     input.createEmptyCommit = true;
-    assertThat(name).isEqualTo(gApi.projects().create(input).get().name);
+    assertThat(gApi.projects().create(input).get().name).isEqualTo(name);
 
     RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
     eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
@@ -103,9 +158,34 @@
   }
 
   @Test
-  public void createBranch() throws Exception {
-    allow("refs/*", Permission.READ, ANONYMOUS_USERS);
+  public void createAndDeleteBranch() throws Exception {
+    assertThat(getRemoteHead(project.get(), "foo")).isNull();
+
     gApi.projects().name(project.get()).branch("foo").create(new BranchInput());
+    assertThat(getRemoteHead(project.get(), "foo")).isNotNull();
+    projectIndexedCounter.assertNoReindex();
+
+    gApi.projects().name(project.get()).branch("foo").delete();
+    assertThat(getRemoteHead(project.get(), "foo")).isNull();
+    projectIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void createAndDeleteBranchByPush() throws Exception {
+    grant(project, "refs/*", Permission.PUSH, true);
+    projectIndexedCounter.clear();
+
+    assertThat(getRemoteHead(project.get(), "foo")).isNull();
+
+    PushOneCommit.Result r = pushTo("refs/heads/foo");
+    r.assertOkStatus();
+    assertThat(getRemoteHead(project.get(), "foo")).isEqualTo(r.getCommit());
+    projectIndexedCounter.assertNoReindex();
+
+    PushResult r2 = GitUtil.pushOne(testRepo, null, "refs/heads/foo", false, true, null);
+    assertThat(r2.getRemoteUpdate("refs/heads/foo").getStatus()).isEqualTo(Status.OK);
+    assertThat(getRemoteHead(project.get(), "foo")).isNull();
+    projectIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -204,10 +284,45 @@
     ConfigInput input = createTestConfigInput();
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("restricted to project owner");
+    exception.expectMessage("write config not permitted");
     gApi.projects().name(project.get()).config(input);
   }
 
+  @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("set head not permitted");
+    gApi.projects().name(project.get()).head("test");
+  }
+
   private ConfigInput createTestConfigInput() {
     ConfigInput input = new ConfigInput();
     input.description = "some description";
@@ -224,4 +339,35 @@
     input.state = ProjectState.HIDDEN;
     return input;
   }
+
+  private static class ProjectIndexedCounter implements ProjectIndexedListener {
+    private final AtomicLongMap<String> countsByProject = AtomicLongMap.create();
+
+    @Override
+    public void onProjectIndexed(String project) {
+      countsByProject.incrementAndGet(project);
+    }
+
+    void clear() {
+      countsByProject.clear();
+    }
+
+    long getCount(String projectName) {
+      return countsByProject.get(projectName);
+    }
+
+    void assertReindexOf(String projectName) {
+      assertReindexOf(projectName, 1);
+    }
+
+    void assertReindexOf(String projectName, int expectedCount) {
+      assertThat(getCount(projectName)).isEqualTo(expectedCount);
+      assertThat(countsByProject).hasSize(1);
+      clear();
+    }
+
+    void assertNoReindex() {
+      assertThat(countsByProject).isEmpty();
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/SetParentIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/SetParentIT.java
new file mode 100644
index 0000000..486a29e
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -0,0 +1,89 @@
+// 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.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.extensions.restapi.AuthException;
+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.config.AllProjectsNameProvider;
+import org.junit.Test;
+
+@NoHttpd
+public class SetParentIT extends AbstractDaemonTest {
+  @Test
+  public void setParentNotAllowed() throws Exception {
+    String parent = createProject("parent", null, true).get();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.projects().name(project.get()).parent(parent);
+  }
+
+  @Test
+  public void setParent() throws Exception {
+    String parent = createProject("parent", null, true).get();
+
+    gApi.projects().name(project.get()).parent(parent);
+    assertThat(gApi.projects().name(project.get()).parent()).isEqualTo(parent);
+
+    // When the parent name is not explicitly set, it should be
+    // set to "All-Projects".
+    gApi.projects().name(project.get()).parent(null);
+    assertThat(gApi.projects().name(project.get()).parent())
+        .isEqualTo(AllProjectsNameProvider.DEFAULT);
+  }
+
+  @Test
+  public void setParentForAllProjectsNotAllowed() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("cannot set parent of " + AllProjectsNameProvider.DEFAULT);
+    gApi.projects().name(allProjects.get()).parent(project.get());
+  }
+
+  @Test
+  public void setParentToSelfNotAllowed() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("cannot set parent to self");
+    gApi.projects().name(project.get()).parent(project.get());
+  }
+
+  @Test
+  public void setParentToOwnChildNotAllowed() throws Exception {
+    String child = createProject("child", project, true).get();
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("cycle exists between");
+    gApi.projects().name(project.get()).parent(child);
+  }
+
+  @Test
+  public void setParentToGrandchildNotAllowed() throws Exception {
+    Project.NameKey child = createProject("child", project, true);
+    String grandchild = createProject("grandchild", child, true).get();
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("cycle exists between");
+    gApi.projects().name(project.get()).parent(grandchild);
+  }
+
+  @Test
+  public void setParentToNonexistentProject() throws Exception {
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("not found");
+    gApi.projects().name(project.get()).parent("non-existing");
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD
index 990bad6..6bfecfa 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD
@@ -4,7 +4,4 @@
     srcs = ["ChangeEditIT.java"],
     group = "edit",
     labels = ["edit"],
-    deps = [
-        "//lib/joda:joda-time",
-    ],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD
index 43ec5bc..897b99f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD
@@ -16,7 +16,6 @@
     srcs = ["AbstractPushForReview.java"],
     deps = [
         "//gerrit-acceptance-tests:lib",
-        "//lib/joda:joda-time",
     ],
 )
 
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 36843a5..9b88e0d 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
@@ -146,7 +146,6 @@
 
     ReviewInput in = new ReviewInput();
     in.onBehalfOf = user.id.toString();
-    in.strictLabels = true;
     in.label("Not-A-Label", 5);
 
     exception.expect(BadRequestException.class);
@@ -155,23 +154,6 @@
   }
 
   @Test
-  public void voteOnBehalfOfInvalidLabelIgnoredWithoutStrictLabels() throws Exception {
-    allowCodeReviewOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-
-    ReviewInput in = new ReviewInput();
-    in.onBehalfOf = user.id.toString();
-    in.strictLabels = false;
-    in.label("Code-Review", 1);
-    in.label("Not-A-Label", 5);
-
-    revision.review(in);
-
-    assertThat(gApi.changes().id(r.getChangeId()).get().labels).doesNotContainKey("Not-A-Label");
-  }
-
-  @Test
   public void voteOnBehalfOfLabelNotPermitted() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     LabelType verified = Util.verified();
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 7de9d70..1558988 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,13 +18,14 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.account.PutUsername;
+import com.google.gerrit.extensions.api.accounts.UsernameInput;
+
 import org.junit.Test;
 
 public class PutUsernameIT extends AbstractDaemonTest {
   @Test
   public void set() throws Exception {
-    PutUsername.Input in = new PutUsername.Input();
+    UsernameInput in = new UsernameInput();
     in.username = "myUsername";
     RestResponse r =
         adminRestSession.put("/accounts/" + accountCreator.create().id.get() + "/username", in);
@@ -34,7 +35,7 @@
 
   @Test
   public void setExisting_Conflict() throws Exception {
-    PutUsername.Input in = new PutUsername.Input();
+    UsernameInput in = new UsernameInput();
     in.username = admin.username;
     adminRestSession
         .put("/accounts/" + accountCreator.create().id.get() + "/username", in)
@@ -43,7 +44,7 @@
 
   @Test
   public void setNew_MethodNotAllowed() throws Exception {
-    PutUsername.Input in = new PutUsername.Input();
+    UsernameInput in = new UsernameInput();
     in.username = "newUsername";
     adminRestSession.put("/accounts/" + admin.username + "/username", in).assertMethodNotAllowed();
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
index b7ed2e8..49f00f9 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
@@ -15,7 +15,6 @@
     labels = ["rest"],
     deps = [
         ":submit_util",
-        "//lib/joda:joda-time",
     ],
 )
 
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 8388ed0..37d3e1e 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,6 +22,7 @@
 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;
@@ -33,6 +34,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.Util;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -227,6 +229,59 @@
     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);
+  }
+
   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/project/BanCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
index 90d51e0..14fa715 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
@@ -21,7 +21,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.project.BanCommit;
+import com.google.gerrit.extensions.api.projects.BanCommitInput;
 import com.google.gerrit.server.project.BanCommit.BanResultInfo;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -35,7 +35,7 @@
 
     RestResponse r =
         adminRestSession.put(
-            "/projects/" + project.get() + "/ban/", BanCommit.Input.fromCommits(c.name()));
+            "/projects/" + project.get() + "/ban/", BanCommitInput.fromCommits(c.name()));
     r.assertOK();
     BanResultInfo info = newGson().fromJson(r.getReader(), BanResultInfo.class);
     assertThat(Iterables.getOnlyElement(info.newlyBanned)).isEqualTo(c.name());
@@ -54,13 +54,13 @@
     RestResponse r =
         adminRestSession.put(
             "/projects/" + project.get() + "/ban/",
-            BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
+            BanCommitInput.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
     r.consume();
 
     r =
         adminRestSession.put(
             "/projects/" + project.get() + "/ban/",
-            BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
+            BanCommitInput.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
     r.assertOK();
     BanResultInfo info = newGson().fromJson(r.getReader(), BanResultInfo.class);
     assertThat(Iterables.getOnlyElement(info.alreadyBanned))
@@ -74,7 +74,7 @@
     userRestSession
         .put(
             "/projects/" + project.get() + "/ban/",
-            BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"))
+            BanCommitInput.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"))
         .assertForbidden();
   }
 }
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 0409fbc..b47b51a 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
@@ -43,6 +43,12 @@
 import com.google.gerrit.server.project.ProjectState;
 import java.util.Collections;
 import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import org.apache.http.HttpStatus;
 import org.apache.http.message.BasicHeader;
 import org.eclipse.jgit.lib.Constants;
@@ -85,6 +91,30 @@
   }
 
   @Test
+  public void createSameProjectFromTwoConcurrentRequests() throws Exception {
+    ExecutorService executor = Executors.newFixedThreadPool(2);
+    try {
+      for (int i = 0; i < 10; i++) {
+        String newProjectName = name("foo" + i);
+        CyclicBarrier sync = new CyclicBarrier(2);
+        Callable<RestResponse> createProjectFoo =
+            () -> {
+              sync.await();
+              return adminRestSession.put("/projects/" + newProjectName);
+            };
+
+        Future<RestResponse> r1 = executor.submit(createProjectFoo);
+        Future<RestResponse> r2 = executor.submit(createProjectFoo);
+        assertThat(ImmutableList.of(r1.get().getStatusCode(), r2.get().getStatusCode()))
+            .containsAllOf(HttpStatus.SC_CREATED, HttpStatus.SC_CONFLICT);
+      }
+    } finally {
+      executor.shutdown();
+      executor.awaitTermination(5, TimeUnit.SECONDS);
+    }
+  }
+
+  @Test
   @UseLocalDisk
   public void createProjectHttpWithUnreasonableName_BadRequest() throws Exception {
     ImmutableList<String> forbiddenStrings =
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java
deleted file mode 100644
index 841e398..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java
+++ /dev/null
@@ -1,105 +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.project;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
-import com.google.gerrit.server.project.SetParent;
-import org.junit.Test;
-
-public class SetParentIT extends AbstractDaemonTest {
-  @Test
-  public void setParent_Forbidden() throws Exception {
-    String parent = createProject("parent", null, true).get();
-    RestResponse r =
-        userRestSession.put("/projects/" + project.get() + "/parent", newParentInput(parent));
-    r.assertForbidden();
-    r.consume();
-  }
-
-  @Test
-  public void setParent() throws Exception {
-    String parent = createProject("parent", null, true).get();
-    RestResponse r =
-        adminRestSession.put("/projects/" + project.get() + "/parent", newParentInput(parent));
-    r.assertOK();
-    r.consume();
-
-    r = adminRestSession.get("/projects/" + project.get() + "/parent");
-    r.assertOK();
-    String newParent = newGson().fromJson(r.getReader(), String.class);
-    assertThat(newParent).isEqualTo(parent);
-    r.consume();
-
-    // When the parent name is not explicitly set, it should be
-    // set to "All-Projects".
-    r = adminRestSession.put("/projects/" + project.get() + "/parent", newParentInput(null));
-    r.assertOK();
-    r.consume();
-
-    r = adminRestSession.get("/projects/" + project.get() + "/parent");
-    r.assertOK();
-    newParent = newGson().fromJson(r.getReader(), String.class);
-    assertThat(newParent).isEqualTo(AllProjectsNameProvider.DEFAULT);
-    r.consume();
-  }
-
-  @Test
-  public void setParentForAllProjects_Conflict() throws Exception {
-    RestResponse r =
-        adminRestSession.put(
-            "/projects/" + allProjects.get() + "/parent", newParentInput(project.get()));
-    r.assertConflict();
-    r.consume();
-  }
-
-  @Test
-  public void setInvalidParent_Conflict() throws Exception {
-    RestResponse r =
-        adminRestSession.put(
-            "/projects/" + project.get() + "/parent", newParentInput(project.get()));
-    r.assertConflict();
-    r.consume();
-
-    Project.NameKey child = createProject("child", project, true);
-    r = adminRestSession.put("/projects/" + project.get() + "/parent", newParentInput(child.get()));
-    r.assertConflict();
-    r.consume();
-
-    String grandchild = createProject("grandchild", child, true).get();
-    r = adminRestSession.put("/projects/" + project.get() + "/parent", newParentInput(grandchild));
-    r.assertConflict();
-    r.consume();
-  }
-
-  @Test
-  public void setNonExistingParent_UnprocessibleEntity() throws Exception {
-    RestResponse r =
-        adminRestSession.put(
-            "/projects/" + project.get() + "/parent", newParentInput("non-existing"));
-    r.assertUnprocessableEntity();
-    r.consume();
-  }
-
-  SetParent.Input newParentInput(String project) {
-    SetParent.Input in = new SetParent.Input();
-    in.parent = project;
-    return in;
-  }
-}
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 49588e7..81ad9df 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
@@ -54,7 +54,6 @@
     assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
     assertThat(info.changes.get(1).currentRevision).isEqualTo(c1_1.name());
 
-    assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
     RevisionInfo rev = info.changes.get(0).revisions.get(c2_1.name());
     assertThat(rev.files).isNull();
   }
@@ -75,7 +74,6 @@
     assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
     assertThat(info.changes.get(1).currentRevision).isEqualTo(c1_1.name());
 
-    assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
     RevisionInfo rev = info.changes.get(0).revisions.get(c2_1.name());
     assertThat(rev).isNotNull();
     FileInfo file = rev.files.get("b.txt");
@@ -108,7 +106,7 @@
   }
 
   @Test
-  public void respectsWholeTopicAndAncestors() throws Exception {
+  public void respectWholeTopic() throws Exception {
     RevCommit initialHead = getRemoteHead();
     // Create two independent commits and push.
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
@@ -154,7 +152,7 @@
   @Test
   public void topicChaining() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    // Create two independent commits and push.
+
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
     pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
@@ -164,7 +162,7 @@
     String id2 = getChangeId(c2_1);
     pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
 
-    RevCommit c3_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    RevCommit c3_1 = commitBuilder().add("b.txt", "3").message("subject: 3").create();
     String id3 = getChangeId(c3_1);
     pushHead(testRepo, "refs/for/master/" + name("unrelated-topic"), false);
 
@@ -180,6 +178,53 @@
   }
 
   @Test
+  public void respectTopicsOnAncestors() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    String id1 = getChangeId(c1_1);
+    pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
+
+    testRepo.reset(initialHead);
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/master/" + name("otherConnectingTopic"), false);
+
+    RevCommit c3_1 = commitBuilder().add("b.txt", "3").message("subject: 3").create();
+    String id3 = getChangeId(c3_1);
+    pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
+
+    RevCommit c4_1 = commitBuilder().add("b.txt", "4").message("subject: 4").create();
+    String id4 = getChangeId(c4_1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    testRepo.reset(initialHead);
+    RevCommit c5_1 = commitBuilder().add("c.txt", "5").message("subject: 5").create();
+    String id5 = getChangeId(c5_1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    RevCommit c6_1 = commitBuilder().add("c.txt", "6").message("subject: 6").create();
+    String id6 = getChangeId(c6_1);
+    pushHead(testRepo, "refs/for/master/" + name("otherConnectingTopic"), false);
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertSubmittedTogether(id1, id6, id5, id3, id2, id1);
+      assertSubmittedTogether(id2, id6, id5, id2);
+      assertSubmittedTogether(id3, id6, id5, id3, id2, id1);
+      assertSubmittedTogether(id4, id6, id5, id4, id3, id2, id1);
+      assertSubmittedTogether(id5);
+      assertSubmittedTogether(id6, id6, id5, id2);
+    } else {
+      assertSubmittedTogether(id1);
+      assertSubmittedTogether(id2);
+      assertSubmittedTogether(id3, id3, id2);
+      assertSubmittedTogether(id4, id4, id3, id2);
+      assertSubmittedTogether(id5);
+      assertSubmittedTogether(id6, id6, id5);
+    }
+  }
+
+  @Test
   public void newBranchTwoChangesTogether() throws Exception {
     Project.NameKey p1 = createProject("a-new-project", null, false);
     TestRepository<?> repo1 = cloneProject(p1);
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 6f4bdab..32f1ce5 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
@@ -23,8 +23,8 @@
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.server.mail.receive.MailMessage;
+import java.time.Instant;
 import java.util.HashMap;
-import org.joda.time.DateTime;
 import org.junit.Ignore;
 
 @Ignore
@@ -36,7 +36,7 @@
     b.from(user.emailAddress);
     b.addTo(user.emailAddress); // Not evaluated
     b.subject("");
-    b.dateReceived(new DateTime());
+    b.dateReceived(Instant.now());
     return b;
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD
index 71a6135..c3a4e20 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD
@@ -2,7 +2,6 @@
 
 DEPS = [
     "//lib/greenmail",
-    "//lib/joda:joda-time",
     "//lib/mail",
 ]
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index 43f046a..4f51e1f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.server.mail.send.EmailHeader;
+import java.net.URI;
 import java.util.Map;
 import org.junit.Test;
 
@@ -31,9 +32,7 @@
     // Check that the custom address was added as Reply-To
     assertThat(sender.getMessages()).hasSize(1);
     Map<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
-    assertThat(headers.get("Reply-To")).isInstanceOf(EmailHeader.String.class);
-    assertThat(((EmailHeader.String) headers.get("Reply-To")).getString())
-        .isEqualTo("custom@gerritcodereview.com");
+    assertThat(headerString(headers, "Reply-To")).isEqualTo("custom@gerritcodereview.com");
   }
 
   @Test
@@ -42,7 +41,30 @@
     // Check that the user's email was added as Reply-To
     assertThat(sender.getMessages()).hasSize(1);
     Map<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
-    assertThat(headers.get("Reply-To")).isInstanceOf(EmailHeader.String.class);
-    assertThat(((EmailHeader.String) headers.get("Reply-To")).getString()).contains(user.email);
+    assertThat(headerString(headers, "Reply-To")).contains(user.email);
+  }
+
+  @Test
+  public void outgoingMailHasListHeaders() throws Exception {
+    String changeId = createChangeWithReview(user);
+    // Check that the mail has the expected headers
+    assertThat(sender.getMessages()).hasSize(1);
+    Map<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
+    String hostname = URI.create(canonicalWebUrl.get()).getHost();
+    String listId = String.format("<gerrit-%s.%s>", project.get(), hostname);
+    String unsubscribeLink = String.format("<%ssettings>", canonicalWebUrl.get());
+    String threadId =
+        String.format(
+            "<gerrit.%s.%s@%s>",
+            gApi.changes().id(changeId).get().created.getTime(), changeId, hostname);
+    assertThat(headerString(headers, "List-Id")).isEqualTo(listId);
+    assertThat(headerString(headers, "List-Unsubscribe")).isEqualTo(unsubscribeLink);
+    assertThat(headerString(headers, "In-Reply-To")).isEqualTo(threadId);
+  }
+
+  private String headerString(Map<String, EmailHeader> headers, String name) {
+    EmailHeader header = headers.get(name);
+    assertThat(header).isInstanceOf(EmailHeader.String.class);
+    return ((EmailHeader.String) header).getString();
   }
 }
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 0324ffa..e60abc6 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
@@ -43,6 +43,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -326,7 +327,7 @@
   public void restApiNotFoundWhenNoteDbDisabled() throws Exception {
     PushOneCommit.Result r = createChange();
     exception.expect(ResourceNotFoundException.class);
-    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Rebuild.Input());
+    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Input());
   }
 
   @Test
@@ -336,7 +337,7 @@
     setNotesMigration(true, false);
 
     checker.assertNoChangeRef(project, id);
-    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Rebuild.Input());
+    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Input());
     checker.checkChanges(id);
   }
 
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 38ff3c7..75e7553 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;
@@ -80,11 +84,11 @@
 
   @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));
-    ChangeInfo c = get(r.getChangeId());
+    ChangeInfo c = getWithLabels(r);
     LabelInfo q = c.labels.get(label.getName());
     assertThat(q.all).hasSize(1);
     assertThat(q.rejected).isNotNull();
@@ -93,11 +97,11 @@
 
   @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));
-    ChangeInfo c = get(r.getChangeId());
+    ChangeInfo c = getWithLabels(r);
     LabelInfo q = c.labels.get(label.getName());
     assertThat(q.all).hasSize(1);
     assertThat(q.rejected).isNotNull();
@@ -106,11 +110,11 @@
 
   @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));
-    ChangeInfo c = get(r.getChangeId());
+    ChangeInfo c = getWithLabels(r);
     LabelInfo q = c.labels.get(label.getName());
     assertThat(q.all).hasSize(1);
     assertThat(q.rejected).isNotNull();
@@ -119,11 +123,11 @@
 
   @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));
-    ChangeInfo c = get(r.getChangeId());
+    ChangeInfo c = getWithLabels(r);
     LabelInfo q = c.labels.get(label.getName());
     assertThat(q.all).hasSize(1);
     assertThat(q.disliked).isNull();
@@ -133,7 +137,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();
@@ -144,7 +148,7 @@
     input.message = "foo";
 
     revision(r).review(input);
-    ChangeInfo c = get(r.getChangeId());
+    ChangeInfo c = getWithLabels(r);
     LabelInfo q = c.labels.get(P.getName());
     assertThat(q.all).hasSize(2);
     assertThat(q.disliked).isNull();
@@ -158,7 +162,7 @@
     saveLabelConfig();
     PushOneCommit.Result r = createChange();
     revision(r).review(new ReviewInput().label(label.getName(), -1));
-    ChangeInfo c = get(r.getChangeId());
+    ChangeInfo c = getWithLabels(r);
     LabelInfo q = c.labels.get(label.getName());
     assertThat(q.all).hasSize(1);
     assertThat(q.disliked).isNull();
@@ -168,16 +172,16 @@
 
   @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();
     revision(r).review(ReviewInput.approve());
     revision(r).submit();
 
-    ChangeInfo info = get(r.getChangeId(), ListChangesOption.DETAILED_LABELS);
+    ChangeInfo info = getWithLabels(r);
     assertPermitted(info, "Code-Review", 2);
     assertPermitted(info, P.getName(), 0, 1);
     assertPermitted(info, label.getName());
@@ -199,4 +203,8 @@
     cfg.getLabelSections().put(P.getName(), P);
     saveProjectConfig(project, cfg);
   }
+
+  private ChangeInfo getWithLabels(PushOneCommit.Result r) throws Exception {
+    return get(r.getChangeId(), ListChangesOption.LABELS, ListChangesOption.DETAILED_LABELS);
+  }
 }
diff --git a/gerrit-common/BUILD b/gerrit-common/BUILD
index 4389080..d9d4392 100644
--- a/gerrit-common/BUILD
+++ b/gerrit-common/BUILD
@@ -28,7 +28,6 @@
         "//lib:gwtorm_client",
         "//lib:servlet-api-3_1",
         "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/joda:joda-time",
         "//lib/log:api",
     ],
     gwt_xml = SRC + "Common.gwt.xml",
@@ -53,7 +52,6 @@
         "//lib:gwtorm",
         "//lib:servlet-api-3_1",
         "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/joda:joda-time",
         "//lib/log:api",
     ],
 )
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java
index a8e40c6..b1697dc 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java
@@ -15,14 +15,21 @@
 package com.google.gerrit.common;
 
 import com.google.common.annotations.GwtIncompatible;
+import com.google.common.annotations.VisibleForTesting;
 import java.sql.Timestamp;
-import org.joda.time.DateTimeUtils;
+import java.util.function.LongSupplier;
 
 /** Static utility methods for dealing with dates and times. */
-@GwtIncompatible("Unemulated org.joda.time.DateTimeUtils")
+@GwtIncompatible("Unemulated Java 8 functionalities")
 public class TimeUtil {
+  private static final LongSupplier SYSTEM_CURRENT_MILLIS_SUPPLIER = System::currentTimeMillis;
+
+  private static volatile LongSupplier currentMillisSupplier = SYSTEM_CURRENT_MILLIS_SUPPLIER;
+
   public static long nowMs() {
-    return DateTimeUtils.currentTimeMillis();
+    // We should rather use Instant.now(Clock).toEpochMilli() instead but this would require some
+    // changes in our testing code as we wouldn't have clock steps anymore.
+    return currentMillisSupplier.getAsLong();
   }
 
   public static Timestamp nowTs() {
@@ -33,5 +40,15 @@
     return new Timestamp((t.getTime() / 1000) * 1000);
   }
 
+  @VisibleForTesting
+  public static void setCurrentMillisSupplier(LongSupplier customCurrentMillisSupplier) {
+    currentMillisSupplier = customCurrentMillisSupplier;
+  }
+
+  @VisibleForTesting
+  public static void resetCurrentMillisSupplier() {
+    currentMillisSupplier = SYSTEM_CURRENT_MILLIS_SUPPLIER;
+  }
+
   private TimeUtil() {}
 }
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 c90e1fd..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() {
diff --git a/gerrit-elasticsearch/BUILD b/gerrit-elasticsearch/BUILD
index fb86aaf..d278bcf 100644
--- a/gerrit-elasticsearch/BUILD
+++ b/gerrit-elasticsearch/BUILD
@@ -17,10 +17,10 @@
         "//lib/elasticsearch",
         "//lib/elasticsearch:jest",
         "//lib/elasticsearch:jest-common",
+        "//lib/elasticsearch:joda-time",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/joda:joda-time",
         "//lib/log:api",
         "//lib/lucene:lucene-analyzers-common",
         "//lib/lucene:lucene-core",
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 7868443..2d04e11 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
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.project.ProjectIndex;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
@@ -75,6 +76,10 @@
         new FactoryModuleBuilder()
             .implement(GroupIndex.class, ElasticGroupIndex.class)
             .build(GroupIndex.Factory.class));
+    install(
+        new FactoryModuleBuilder()
+            .implement(ProjectIndex.class, ElasticProjectIndex.class)
+            .build(ProjectIndex.Factory.class));
 
     install(new IndexModule(threads));
     if (singleVersions == null) {
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
new file mode 100644
index 0000000..780f023
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
@@ -0,0 +1,215 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
+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.Project;
+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.project.ProjectField;
+import com.google.gerrit.server.index.project.ProjectIndex;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+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 io.searchbox.client.JestResult;
+import io.searchbox.core.Bulk;
+import io.searchbox.core.Bulk.Builder;
+import io.searchbox.core.Search;
+import io.searchbox.core.search.sort.Sort;
+import io.searchbox.core.search.sort.Sort.Sorting;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ElasticProjectIndex extends AbstractElasticIndex<Project.NameKey, ProjectData>
+    implements ProjectIndex {
+  static class ProjectMapping {
+    MappingProperties projects;
+
+    ProjectMapping(Schema<ProjectData> schema) {
+      this.projects = ElasticMapping.createMapping(schema);
+    }
+  }
+
+  static final String PROJECTS = "projects";
+  static final String PROJECTS_PREFIX = PROJECTS + "_";
+
+  private static final Logger log = LoggerFactory.getLogger(ElasticProjectIndex.class);
+
+  private final ProjectMapping mapping;
+  private final Provider<ProjectCache> projectCache;
+
+  @Inject
+  ElasticProjectIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      Provider<ProjectCache> projectCache,
+      JestClientBuilder clientBuilder,
+      @Assisted Schema<ProjectData> schema) {
+    super(cfg, sitePaths, schema, clientBuilder, PROJECTS_PREFIX);
+    this.projectCache = projectCache;
+    this.mapping = new ProjectMapping(schema);
+  }
+
+  @Override
+  public void replace(ProjectData projectState) throws IOException {
+    Bulk bulk =
+        new Bulk.Builder()
+            .defaultIndex(indexName)
+            .defaultType(PROJECTS)
+            .addAction(insert(PROJECTS, projectState))
+            .refresh(true)
+            .build();
+    JestResult result = client.execute(bulk);
+    if (!result.isSucceeded()) {
+      throw new IOException(
+          String.format(
+              "Failed to replace project %s in index %s: %s",
+              projectState.getProject().getName(), indexName, result.getErrorMessage()));
+    }
+  }
+
+  @Override
+  public DataSource<ProjectData> getSource(Predicate<ProjectData> p, QueryOptions opts)
+      throws QueryParseException {
+    return new QuerySource(p, opts);
+  }
+
+  @Override
+  protected Builder addActions(Builder builder, Project.NameKey nameKey) {
+    return builder.addAction(delete(PROJECTS, nameKey));
+  }
+
+  @Override
+  protected String getMappings() {
+    ImmutableMap<String, ProjectMapping> mappings = ImmutableMap.of("mappings", mapping);
+    return gson.toJson(mappings);
+  }
+
+  @Override
+  protected String getId(ProjectData projectState) {
+    return projectState.getProject().getName();
+  }
+
+  private class QuerySource implements DataSource<ProjectData> {
+    private final Search search;
+    private final Set<String> fields;
+
+    QuerySource(Predicate<ProjectData> p, QueryOptions opts) throws QueryParseException {
+      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
+      fields = IndexUtils.projectFields(opts);
+      SearchSourceBuilder searchSource =
+          new SearchSourceBuilder()
+              .query(qb)
+              .from(opts.start())
+              .size(opts.limit())
+              .fields(Lists.newArrayList(fields));
+
+      Sort sort = new Sort(ProjectField.NAME.getName(), Sorting.ASC);
+      sort.setIgnoreUnmapped();
+
+      search =
+          new Search.Builder(searchSource.toString())
+              .addType(PROJECTS)
+              .addIndex(indexName)
+              .addSort(ImmutableList.of(sort))
+              .build();
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<ProjectData> read() throws OrmException {
+      try {
+        List<ProjectData> results = Collections.emptyList();
+        JestResult result = client.execute(search);
+        if (result.isSucceeded()) {
+          JsonObject obj = result.getJsonObject().getAsJsonObject("hits");
+          if (obj.get("hits") != null) {
+            JsonArray json = obj.getAsJsonArray("hits");
+            results = Lists.newArrayListWithCapacity(json.size());
+            for (int i = 0; i < json.size(); i++) {
+              results.add(toProjectData(json.get(i)));
+            }
+          }
+        } else {
+          log.error(result.getErrorMessage());
+        }
+        final List<ProjectData> r = Collections.unmodifiableList(results);
+        return new ResultSet<ProjectData>() {
+          @Override
+          public Iterator<ProjectData> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<ProjectData> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    }
+
+    @Override
+    public String toString() {
+      return search.toString();
+    }
+
+    private ProjectData toProjectData(JsonElement json) {
+      JsonElement source = json.getAsJsonObject().get("_source");
+      if (source == null) {
+        source = json.getAsJsonObject().get("fields");
+      }
+
+      Project.NameKey nameKey =
+          new Project.NameKey(
+              source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
+      return projectCache.get().get(nameKey).toProjectData();
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java
new file mode 100644
index 0000000..4af53e3
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.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.elasticsearch;
+
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticQueryProjectsTest extends AbstractQueryProjectsTest {
+  private static ElasticNodeInfo nodeInfo;
+
+  @BeforeClass
+  public static void startIndexService() throws InterruptedException, ExecutionException {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+    nodeInfo = ElasticTestUtils.startElasticsearchNode();
+    ElasticTestUtils.createAllIndexes(nodeInfo);
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (nodeInfo != null) {
+      nodeInfo.node.close();
+      nodeInfo.elasticDir.delete();
+      nodeInfo = null;
+    }
+  }
+
+  @After
+  public void cleanupIndex() {
+    if (nodeInfo != null) {
+      ElasticTestUtils.deleteAllIndexes(nodeInfo);
+      ElasticTestUtils.createAllIndexes(nodeInfo);
+    }
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port);
+    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
index fac10eb..c37a8ec 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -20,12 +20,14 @@
 import static com.google.gerrit.elasticsearch.ElasticChangeIndex.CLOSED_CHANGES;
 import static com.google.gerrit.elasticsearch.ElasticChangeIndex.OPEN_CHANGES;
 import static com.google.gerrit.elasticsearch.ElasticGroupIndex.GROUPS_PREFIX;
+import static com.google.gerrit.elasticsearch.ElasticProjectIndex.PROJECTS_PREFIX;
 
 import com.google.common.base.Strings;
 import com.google.common.io.Files;
 import com.google.gerrit.elasticsearch.ElasticAccountIndex.AccountMapping;
 import com.google.gerrit.elasticsearch.ElasticChangeIndex.ChangeMapping;
 import com.google.gerrit.elasticsearch.ElasticGroupIndex.GroupMapping;
+import com.google.gerrit.elasticsearch.ElasticProjectIndex.ProjectMapping;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.group.InternalGroup;
@@ -33,6 +35,8 @@
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.server.index.project.ProjectSchemaDefinitions;
+import com.google.gerrit.server.project.ProjectData;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gson.FieldNamingPolicy;
 import com.google.gson.Gson;
@@ -157,6 +161,18 @@
         .addMapping(ElasticGroupIndex.GROUPS, gson.toJson(groupMapping))
         .execute()
         .actionGet();
+
+    Schema<ProjectData> projectSchema = ProjectSchemaDefinitions.INSTANCE.getLatest();
+    ProjectMapping projectMapping = new ProjectMapping(projectSchema);
+    nodeInfo
+        .node
+        .client()
+        .admin()
+        .indices()
+        .prepareCreate(String.format("%s%04d", PROJECTS_PREFIX, projectSchema.getVersion()))
+        .addMapping(ElasticProjectIndex.PROJECTS, gson.toJson(projectMapping))
+        .execute()
+        .actionGet();
   }
 
   private static String getHttpPort(Node node) throws InterruptedException, ExecutionException {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeysInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeysInput.java
new file mode 100644
index 0000000..8fb587a
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeysInput.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.api.accounts;
+
+import java.util.List;
+
+public class GpgKeysInput {
+  public List<String> add;
+  public List<String> delete;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/SshKeyInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/SshKeyInput.java
new file mode 100644
index 0000000..46dd858
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/SshKeyInput.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.api.accounts;
+
+import com.google.gerrit.extensions.restapi.RawInput;
+
+public class SshKeyInput {
+  public RawInput raw;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/StatusInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/StatusInput.java
new file mode 100644
index 0000000..951c049
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/StatusInput.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.extensions.api.accounts;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class StatusInput {
+  public @DefaultInput String status;
+
+  public StatusInput(String status) {
+    this.status = status;
+  }
+
+  public StatusInput() {}
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/UsernameInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/UsernameInput.java
new file mode 100644
index 0000000..f774ddc
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/UsernameInput.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.api.accounts;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class UsernameInput {
+  @DefaultInput public String username;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/TopicInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/TopicInput.java
new file mode 100644
index 0000000..12240d2
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/TopicInput.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.api.changes;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class TopicInput {
+  @DefaultInput public String topic;
+}
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 567d9ba..0243ba3 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
@@ -80,6 +80,7 @@
     private String substring;
     private String suggest;
     private String regex;
+    private String ownedBy;
 
     public List<GroupInfo> get() throws RestApiException {
       Map<String, GroupInfo> map = getAsMap();
@@ -160,6 +161,11 @@
       return this;
     }
 
+    public ListRequest withOwnedBy(String ownedBy) {
+      this.ownedBy = ownedBy;
+      return this;
+    }
+
     public EnumSet<ListGroupsOption> getOptions() {
       return options;
     }
@@ -203,6 +209,10 @@
     public String getSuggest() {
       return suggest;
     }
+
+    public String getOwnedBy() {
+      return ownedBy;
+    }
   }
 
   /**
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/OwnerInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/OwnerInput.java
new file mode 100644
index 0000000..8b0006e
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/OwnerInput.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.api.groups;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class OwnerInput {
+  @DefaultInput public String owner;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BanCommitInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BanCommitInput.java
new file mode 100644
index 0000000..b0f674f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BanCommitInput.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.api.projects;
+
+import com.google.common.collect.Lists;
+import java.util.List;
+
+public class BanCommitInput {
+  public List<String> commits;
+  public String reason;
+
+  public static BanCommitInput fromCommits(String firstCommit, String... moreCommits) {
+    return fromCommits(Lists.asList(firstCommit, moreCommits));
+  }
+
+  public static BanCommitInput fromCommits(List<String> commits) {
+    BanCommitInput in = new BanCommitInput();
+    in.commits = commits;
+    return in;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
index 322b076..672602d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
@@ -14,9 +14,6 @@
 
 package com.google.gerrit.extensions.api.projects;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
-
-public class DescriptionInput {
-  @DefaultInput public String description;
+public class DescriptionInput extends com.google.gerrit.extensions.common.DescriptionInput {
   public String commitMessage;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/HeadInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/HeadInput.java
new file mode 100644
index 0000000..606cf52
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/HeadInput.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.api.projects;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class HeadInput {
+  @DefaultInput public String ref;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ParentInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ParentInput.java
new file mode 100644
index 0000000..6e481ae
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ParentInput.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.api.projects;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class ParentInput {
+  @DefaultInput public String parent;
+  public String commitMessage;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 8320ef7..c9f47c2 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
@@ -170,6 +170,26 @@
 
   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;
+
+  /** Get the name of the project's parent. */
+  String parent() throws RestApiException;
+
+  /**
+   * Set the project's parent.
+   *
+   * @param parent the parent
+   */
+  void parent(String parent) throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -304,5 +324,25 @@
     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();
+    }
+
+    @Override
+    public String parent() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void parent(String parent) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
index e4a659c..02cce3a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
@@ -58,6 +58,24 @@
 
   ListRequest list();
 
+  /**
+   * Query projects.
+   *
+   * <p>Example code: {@code query().withQuery("name:project").get()}
+   *
+   * @return API for setting parameters and getting result.
+   */
+  QueryRequest query();
+
+  /**
+   * Query projects.
+   *
+   * <p>Shortcut API for {@code query().withQuery(String)}.
+   *
+   * @see #query()
+   */
+  QueryRequest query(String query);
+
   abstract class ListRequest {
     public enum FilterType {
       CODE,
@@ -172,6 +190,56 @@
   }
 
   /**
+   * API for setting parameters and getting result. Used for {@code query()}.
+   *
+   * @see #query()
+   */
+  abstract class QueryRequest {
+    private String query;
+    private int limit;
+    private int start;
+
+    /** Execute query and returns the matched projects as list. */
+    public abstract List<ProjectInfo> get() throws RestApiException;
+
+    /**
+     * Set query.
+     *
+     * @param query needs to be in human-readable form.
+     */
+    public QueryRequest withQuery(String query) {
+      this.query = query;
+      return this;
+    }
+
+    /**
+     * Set limit for returned list of projects. Optional; server-default is used when not provided.
+     */
+    public QueryRequest withLimit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    /** Set number of projects to skip. Optional; no projects are skipped when not provided. */
+    public QueryRequest withStart(int start) {
+      this.start = start;
+      return this;
+    }
+
+    public String getQuery() {
+      return query;
+    }
+
+    public int getLimit() {
+      return limit;
+    }
+
+    public int getStart() {
+      return start;
+    }
+  }
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -195,5 +263,15 @@
     public ListRequest list() {
       throw new NotImplementedException();
     }
+
+    @Override
+    public QueryRequest query() {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public QueryRequest query(String query) {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DescriptionInput.java
new file mode 100644
index 0000000..c0733dc
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DescriptionInput.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.common;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class DescriptionInput {
+  @DefaultInput public String description;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/HttpPasswordInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/HttpPasswordInput.java
new file mode 100644
index 0000000..246c7cf
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/HttpPasswordInput.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.common;
+
+public class HttpPasswordInput {
+  public String httpPassword;
+  public boolean generate;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Input.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Input.java
new file mode 100644
index 0000000..68f864c
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Input.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.common;
+
+/** A generic empty input. */
+public class Input {
+  public Input() {}
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/NameInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/NameInput.java
new file mode 100644
index 0000000..463eee1
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/NameInput.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.common;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class NameInput {
+  @DefaultInput public String name;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectIndexedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectIndexedListener.java
new file mode 100644
index 0000000..93a610b
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectIndexedListener.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.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Notified whenever a project is indexed */
+@ExtensionPoint
+public interface ProjectIndexedListener {
+  /**
+   * Invoked when a project is indexed
+   *
+   * @param project name of the project
+   */
+  void onProjectIndexed(String project);
+}
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 49c7f67..0df0aa9 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
@@ -15,6 +15,7 @@
 package com.google.gerrit.gpg.api;
 
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
+import com.google.gerrit.extensions.api.accounts.GpgKeysInput;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -75,7 +76,7 @@
   public Map<String, GpgKeyInfo> putGpgKeys(
       AccountResource account, List<String> add, List<String> delete)
       throws RestApiException, GpgException {
-    PostGpgKeys.Input in = new PostGpgKeys.Input();
+    GpgKeysInput in = new GpgKeysInput();
     in.add = add;
     in.delete = delete;
     try {
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 14a4c6d..25b472d 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
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.gpg.server.DeleteGpgKey;
 import com.google.gerrit.gpg.server.GpgKey;
@@ -55,7 +56,7 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      delete.apply(rsrc, new DeleteGpgKey.Input());
+      delete.apply(rsrc, new Input());
     } catch (PGPException | OrmException | IOException | ConfigInvalidException e) {
       throw new RestApiException("Cannot delete GPG key", e);
     }
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 baf5a58..b9d89ee 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
@@ -18,11 +18,11 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.gpg.server.DeleteGpgKey.Input;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
@@ -38,7 +38,6 @@
 import org.eclipse.jgit.lib.RefUpdate;
 
 public class DeleteGpgKey implements RestModifyView<GpgKey, Input> {
-  public static class Input {}
 
   private final Provider<PersonIdent> serverIdent;
   private final Provider<PublicKeyStore> storeProvider;
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 7d1aceed..8014574 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
@@ -28,6 +28,7 @@
 import com.google.common.collect.Sets;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.accounts.GpgKeysInput;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -38,7 +39,6 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.gpg.server.PostGpgKeys.Input;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -75,12 +75,7 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class PostGpgKeys implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    public List<String> add;
-    public List<String> delete;
-  }
-
+public class PostGpgKeys implements RestModifyView<AccountResource, GpgKeysInput> {
   private final Logger log = LoggerFactory.getLogger(getClass());
   private final Provider<PersonIdent> serverIdent;
   private final Provider<CurrentUser> self;
@@ -112,7 +107,7 @@
   }
 
   @Override
-  public Map<String, GpgKeyInfo> apply(AccountResource rsrc, Input input)
+  public Map<String, GpgKeyInfo> apply(AccountResource rsrc, GpgKeysInput input)
       throws ResourceNotFoundException, BadRequestException, ResourceConflictException,
           PGPException, OrmException, IOException, ConfigInvalidException {
     GpgKeys.checkVisible(self, rsrc);
@@ -148,7 +143,8 @@
     }
   }
 
-  private Set<Fingerprint> readKeysToRemove(Input input, Collection<ExternalId> existingExtIds) {
+  private Set<Fingerprint> readKeysToRemove(
+      GpgKeysInput input, Collection<ExternalId> existingExtIds) {
     if (input.delete == null || input.delete.isEmpty()) {
       return ImmutableSet.of();
     }
@@ -163,7 +159,7 @@
     return fingerprints;
   }
 
-  private List<PGPPublicKeyRing> readKeysToAdd(Input input, Set<Fingerprint> toRemove)
+  private List<PGPPublicKeyRing> readKeysToAdd(GpgKeysInput input, Set<Fingerprint> toRemove)
       throws BadRequestException, IOException {
     if (input.add == null || input.add.isEmpty()) {
       return ImmutableList.of();
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-httpd/BUILD b/gerrit-httpd/BUILD
index dbca10c..cc2160f 100644
--- a/gerrit-httpd/BUILD
+++ b/gerrit-httpd/BUILD
@@ -77,6 +77,5 @@
         "//lib/guice:guice-servlet",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
-        "//lib/joda:joda-time",
     ],
 )
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index 329beab..4d472da 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
@@ -34,8 +34,8 @@
 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.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -80,7 +80,7 @@
 public class GitOverHttpServlet extends GitServlet {
   private static final long serialVersionUID = 1L;
 
-  private static final String ATT_CONTROL = ProjectControl.class.getName();
+  private static final String ATT_STATE = ProjectState.class.getName();
   private static final String ATT_ARC = AsyncReceiveCommits.class.getName();
   private static final String ID_CACHE = "adv_bases";
 
@@ -145,18 +145,18 @@
     private final GitRepositoryManager manager;
     private final PermissionBackend permissionBackend;
     private final Provider<CurrentUser> userProvider;
-    private final ProjectControl.GenericFactory projectControlFactory;
+    private final ProjectCache projectCache;
 
     @Inject
     Resolver(
         GitRepositoryManager manager,
         PermissionBackend permissionBackend,
         Provider<CurrentUser> userProvider,
-        ProjectControl.GenericFactory projectControlFactory) {
+        ProjectCache projectCache) {
       this.manager = manager;
       this.permissionBackend = permissionBackend;
       this.userProvider = userProvider;
-      this.projectControlFactory = projectControlFactory;
+      this.projectCache = projectCache;
     }
 
     @Override
@@ -182,13 +182,11 @@
 
       try {
         Project.NameKey nameKey = new Project.NameKey(projectName);
-        ProjectControl pc;
-        try {
-          pc = projectControlFactory.controlFor(nameKey, user);
-        } catch (NoSuchProjectException err) {
-          throw new RepositoryNotFoundException(projectName);
+        ProjectState state = projectCache.checkedGet(nameKey);
+        if (state == null) {
+          throw new RepositoryNotFoundException(nameKey.get());
         }
-        req.setAttribute(ATT_CONTROL, pc);
+        req.setAttribute(ATT_STATE, state);
 
         try {
           permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
@@ -231,9 +229,9 @@
       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);
+      ProjectState state = (ProjectState) req.getAttribute(ATT_STATE);
       for (UploadPackInitializer initializer : uploadPackInitializers) {
-        initializer.init(pc.getProject().getNameKey(), up);
+        initializer.init(state.getNameKey(), up);
       }
       return up;
     }
@@ -243,15 +241,18 @@
     private final VisibleRefFilter.Factory refFilterFactory;
     private final UploadValidators.Factory uploadValidatorsFactory;
     private final PermissionBackend permissionBackend;
+    private final Provider<CurrentUser> userProvider;
 
     @Inject
     UploadFilter(
         VisibleRefFilter.Factory refFilterFactory,
         UploadValidators.Factory uploadValidatorsFactory,
-        PermissionBackend permissionBackend) {
+        PermissionBackend permissionBackend,
+        Provider<CurrentUser> userProvider) {
       this.refFilterFactory = refFilterFactory;
       this.uploadValidatorsFactory = uploadValidatorsFactory;
       this.permissionBackend = permissionBackend;
+      this.userProvider = userProvider;
     }
 
     @Override
@@ -259,13 +260,13 @@
         throws IOException, ServletException {
       // The Resolver above already checked READ access for us.
       Repository repo = ServletUtils.getRepository(request);
-      ProjectControl pc = (ProjectControl) request.getAttribute(ATT_CONTROL);
+      ProjectState state = (ProjectState) request.getAttribute(ATT_STATE);
       UploadPack up = (UploadPack) request.getAttribute(ServletUtils.ATTRIBUTE_HANDLER);
 
       try {
         permissionBackend
-            .user(pc.getUser())
-            .project(pc.getProject().getNameKey())
+            .user(userProvider)
+            .project(state.getNameKey())
             .check(ProjectPermission.RUN_UPLOAD_PACK);
       } catch (AuthException e) {
         GitSmartHttpTools.sendError(
@@ -280,10 +281,10 @@
       // 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());
+          uploadValidatorsFactory.create(state.getProject(), repo, request.getRemoteHost());
       up.setPreUploadHook(
           PreUploadHookChain.newChain(Lists.newArrayList(up.getPreUploadHook(), uploadValidators)));
-      up.setAdvertiseRefsHook(refFilterFactory.create(pc.getProjectState(), repo));
+      up.setAdvertiseRefsHook(refFilterFactory.create(state, repo));
 
       next.doFilter(request, response);
     }
@@ -297,23 +298,27 @@
 
   static class ReceiveFactory implements ReceivePackFactory<HttpServletRequest> {
     private final AsyncReceiveCommits.Factory factory;
+    private final Provider<CurrentUser> userProvider;
 
     @Inject
-    ReceiveFactory(AsyncReceiveCommits.Factory factory) {
+    ReceiveFactory(AsyncReceiveCommits.Factory factory, Provider<CurrentUser> userProvider) {
       this.factory = factory;
+      this.userProvider = userProvider;
     }
 
     @Override
     public ReceivePack create(HttpServletRequest req, Repository db)
         throws ServiceNotAuthorizedException {
-      final ProjectControl pc = (ProjectControl) req.getAttribute(ATT_CONTROL);
+      final ProjectState state = (ProjectState) req.getAttribute(ATT_STATE);
 
-      if (!(pc.getUser().isIdentifiedUser())) {
+      if (!(userProvider.get().isIdentifiedUser())) {
         // Anonymous users are not permitted to push.
         throw new ServiceNotAuthorizedException();
       }
 
-      AsyncReceiveCommits arc = factory.create(pc, db, null, ImmutableSetMultimap.of());
+      AsyncReceiveCommits arc =
+          factory.create(
+              state, userProvider.get().asIdentifiedUser(), db, null, ImmutableSetMultimap.of());
       ReceivePack rp = arc.getReceivePack();
       req.setAttribute(ATT_ARC, arc);
       return rp;
@@ -331,13 +336,16 @@
   static class ReceiveFilter implements Filter {
     private final Cache<AdvertisedObjectsCacheKey, Set<ObjectId>> cache;
     private final PermissionBackend permissionBackend;
+    private final Provider<CurrentUser> userProvider;
 
     @Inject
     ReceiveFilter(
         @Named(ID_CACHE) Cache<AdvertisedObjectsCacheKey, Set<ObjectId>> cache,
-        PermissionBackend permissionBackend) {
+        PermissionBackend permissionBackend,
+        Provider<CurrentUser> userProvider) {
       this.cache = cache;
       this.permissionBackend = permissionBackend;
+      this.userProvider = userProvider;
     }
 
     @Override
@@ -348,14 +356,15 @@
       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();
+      ProjectState state = (ProjectState) request.getAttribute(ATT_STATE);
 
+      Capable s;
       try {
         permissionBackend
-            .user(pc.getUser())
-            .project(pc.getProject().getNameKey())
+            .user(userProvider)
+            .project(state.getNameKey())
             .check(ProjectPermission.RUN_RECEIVE_PACK);
+        s = arc.canUpload();
       } catch (AuthException e) {
         GitSmartHttpTools.sendError(
             (HttpServletRequest) request,
@@ -367,7 +376,6 @@
         throw new RuntimeException(e);
       }
 
-      Capable s = arc.canUpload();
       if (s != Capable.OK) {
         GitSmartHttpTools.sendError(
             (HttpServletRequest) request,
@@ -382,13 +390,13 @@
         return;
       }
 
-      if (!(pc.getUser().isIdentifiedUser())) {
+      if (!(userProvider.get().isIdentifiedUser())) {
         chain.doFilter(request, response);
         return;
       }
 
       AdvertisedObjectsCacheKey cacheKey =
-          AdvertisedObjectsCacheKey.create(pc.getUser().getAccountId(), projectName);
+          AdvertisedObjectsCacheKey.create(userProvider.get().getAccountId(), state.getNameKey());
 
       if (isGet) {
         cache.invalidate(cacheKey);
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 8ceb50a..1c7d508 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
@@ -146,7 +146,7 @@
     }
 
     if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP) {
-      return failAuthentication(rsp, username);
+      return failAuthentication(rsp, username, req);
     }
 
     AuthRequest whoAuth = AuthRequest.forUser(username);
@@ -160,15 +160,15 @@
       if (who.checkPassword(password, who.getUserName())) {
         return succeedAuthentication(who);
       }
-      log.warn("Authentication failed for " + username, e);
+      log.warn(authenticationFailedMsg(username, req), e);
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     } catch (AuthenticationFailedException e) {
-      log.warn("Authentication failed for " + username + ": " + e.getMessage());
+      log.warn(authenticationFailedMsg(username, req) + ": " + e.getMessage());
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     } catch (AccountException e) {
-      log.warn("Authentication failed for " + username, e);
+      log.warn(authenticationFailedMsg(username, req), e);
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
@@ -179,13 +179,20 @@
     return true;
   }
 
-  private boolean failAuthentication(Response rsp, String username) throws IOException {
+  private boolean failAuthentication(Response rsp, String username, HttpServletRequest req)
+      throws IOException {
     log.warn(
-        "Authentication failed for {}: password does not match the one stored in Gerrit", username);
+        authenticationFailedMsg(username, req)
+            + ": password does not match the one stored in Gerrit",
+        username);
     rsp.sendError(SC_UNAUTHORIZED);
     return false;
   }
 
+  static String authenticationFailedMsg(String username, HttpServletRequest req) {
+    return String.format("Authentication from %s failed for %s", req.getRemoteAddr(), username);
+  }
+
   private void setUserIdentified(Account.Id id) {
     WebSession ws = session.get();
     ws.setUserAccountId(id);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
index 1f21da2..b910509 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.httpd;
 
+import static com.google.gerrit.httpd.ProjectBasicAuthFilter.authenticationFailedMsg;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
 
@@ -153,8 +154,7 @@
     AccountState who = accountCache.getByUsername(authInfo.username);
     if (who == null || !who.getAccount().isActive()) {
       log.warn(
-          "Authentication failed for "
-              + authInfo.username
+          authenticationFailedMsg(authInfo.username, req)
               + ": account inactive or not provisioned in Gerrit");
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
@@ -175,7 +175,7 @@
       ws.setAccessPathOk(AccessPath.REST_API, true);
       return true;
     } catch (AccountException e) {
-      log.warn("Authentication failed for " + authInfo.username, e);
+      log.warn(authenticationFailedMsg(authInfo.username, req), e);
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
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 2adf029..62232db 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
@@ -19,16 +19,17 @@
 import com.google.gerrit.common.data.ProjectAccess;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.PermissionBackend;
 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;
 import com.google.gerrit.server.project.SetParent;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -56,7 +57,6 @@
   @Inject
   ChangeProjectAccess(
       ProjectAccessFactory.Factory projectAccessFactory,
-      ProjectControl.Factory projectControlFactory,
       ProjectCache projectCache,
       GroupBackend groupBackend,
       MetaDataUpdate.User metaDataUpdateFactory,
@@ -64,23 +64,26 @@
       Provider<SetParent> setParent,
       GitReferenceUpdated gitRefUpdated,
       ContributorAgreementsChecker contributorAgreements,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
       @Assisted("projectName") Project.NameKey projectName,
       @Nullable @Assisted ObjectId base,
       @Assisted List<AccessSection> sectionList,
       @Nullable @Assisted("parentProjectName") Project.NameKey parentProjectName,
       @Nullable @Assisted String message) {
     super(
-        projectControlFactory,
         groupBackend,
         metaDataUpdateFactory,
         allProjects,
         setParent,
+        user.get(),
         projectName,
         base,
         sectionList,
         parentProjectName,
         message,
         contributorAgreements,
+        permissionBackend,
         true);
     this.projectAccessFactory = projectAccessFactory;
     this.projectCache = projectCache;
@@ -89,10 +92,7 @@
 
   @Override
   protected ProjectAccess updateProjectConfig(
-      ProjectControl projectControl,
-      ProjectConfig config,
-      MetaDataUpdate md,
-      boolean parentProjectUpdate)
+      ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
       throws IOException, NoSuchProjectException, ConfigInvalidException,
           PermissionBackendException {
     RevCommit commit = config.commit(md);
@@ -102,7 +102,7 @@
         RefNames.REFS_CONFIG,
         base,
         commit.getId(),
-        projectControl.getUser().asIdentifiedUser().getAccount());
+        user.asIdentifiedUser().getAccount());
 
     projectCache.evict(config.getProject());
     return projectAccessFactory.create(projectName).call();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
index 4cd6fa0..98f6b3f 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
@@ -17,6 +17,7 @@
 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 static com.google.gerrit.server.permissions.RefPermission.WRITE_CONFIG;
 
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.data.AccessSection;
@@ -47,7 +48,7 @@
 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.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -69,7 +70,6 @@
   private final ProjectCache projectCache;
   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;
@@ -83,7 +83,6 @@
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
       Provider<CurrentUser> user,
-      ProjectControl.GenericFactory projectControlFactory,
       GroupControl.Factory groupControlFactory,
       MetaDataUpdate.Server metaDataUpdateFactory,
       AllProjectsName allProjectsName,
@@ -93,7 +92,6 @@
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.user = user;
-    this.projectControlFactory = projectControlFactory;
     this.groupControlFactory = groupControlFactory;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allProjectsName = allProjectsName;
@@ -106,7 +104,7 @@
   public ProjectAccess call()
       throws NoSuchProjectException, IOException, ConfigInvalidException,
           PermissionBackendException {
-    ProjectControl pc = checkProjectControl();
+    ProjectState projectState = checkProjectState();
 
     // Load the current configuration from the repository, ensuring its the most
     // recent version available. If it differs from what was in the project
@@ -119,11 +117,11 @@
         md.setMessage("Update group names\n");
         config.commit(md);
         projectCache.evict(config.getProject());
-        pc = checkProjectControl();
+        projectState = checkProjectState();
       } else if (config.getRevision() != null
-          && !config.getRevision().equals(pc.getProjectState().getConfig().getRevision())) {
+          && !config.getRevision().equals(projectState.getConfig().getRevision())) {
         projectCache.evict(config.getProject());
-        pc = checkProjectControl();
+        projectState = checkProjectState();
       }
     }
 
@@ -132,11 +130,17 @@
     Map<AccountGroup.UUID, Boolean> visibleGroups = new HashMap<>();
     PermissionBackend.ForProject perm = permissionBackend.user(user).project(projectName);
     boolean checkReadConfig = check(perm, RefNames.REFS_CONFIG, READ);
+    boolean canWriteProjectConfig = true;
+    try {
+      perm.check(ProjectPermission.WRITE_CONFIG);
+    } catch (AuthException e) {
+      canWriteProjectConfig = false;
+    }
 
     for (AccessSection section : config.getAccessSections()) {
       String name = section.getName();
       if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
-        if (pc.isOwner()) {
+        if (canWriteProjectConfig) {
           local.add(section);
           ownerOf.add(name);
 
@@ -145,7 +149,7 @@
         }
 
       } else if (RefConfigSection.isValid(name)) {
-        if (pc.controlForRef(name).isOwner()) {
+        if (check(perm, name, WRITE_CONFIG)) {
           local.add(section);
           ownerOf.add(name);
 
@@ -217,11 +221,11 @@
     detail.setLocal(local);
     detail.setOwnerOf(ownerOf);
     detail.setCanUpload(
-        pc.isOwner()
+        canWriteProjectConfig
             || (checkReadConfig && perm.ref(RefNames.REFS_CONFIG).testOrFalse(CREATE_CHANGE)));
-    detail.setConfigVisible(pc.isOwner() || checkReadConfig);
+    detail.setConfigVisible(canWriteProjectConfig || checkReadConfig);
     detail.setGroupInfo(buildGroupInfo(local));
-    detail.setLabelTypes(pc.getProjectState().getLabelTypes());
+    detail.setLabelTypes(projectState.getLabelTypes());
     detail.setFileHistoryLinks(getConfigFileLogLinks(projectName.get()));
     return detail;
   }
@@ -251,15 +255,15 @@
     return Maps.filterEntries(infos, in -> in.getValue() != null);
   }
 
-  private ProjectControl checkProjectControl()
+  private ProjectState checkProjectState()
       throws NoSuchProjectException, IOException, PermissionBackendException {
-    ProjectControl pc = projectControlFactory.controlFor(projectName, user.get());
+    ProjectState state = projectCache.checkedGet(projectName);
     try {
       permissionBackend.user(user).project(projectName).check(ProjectPermission.ACCESS);
     } catch (AuthException e) {
       throw new NoSuchProjectException(projectName);
     }
-    return pc;
+    return state;
   }
 
   private static boolean check(PermissionBackend.ForProject ctx, String ref, RefPermission perm)
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 3fa05ab..5bde72b 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
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.httpd.rpc.project;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.common.ProjectAccessUtil.mergeSections;
 
 import com.google.common.base.MoreObjects;
@@ -30,15 +31,18 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.httpd.rpc.Handler;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.config.AllProjectsName;
 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.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
 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;
 import com.google.gerrit.server.project.SetParent;
 import com.google.gwtorm.server.OrmException;
@@ -53,38 +57,43 @@
 
 public abstract class ProjectAccessHandler<T> extends Handler<T> {
 
-  private final ProjectControl.Factory projectControlFactory;
   protected final GroupBackend groupBackend;
+  protected final Project.NameKey projectName;
+  protected final ObjectId base;
+  protected final CurrentUser user;
+
   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;
-  private List<AccessSection> sectionList;
+  private final PermissionBackend permissionBackend;
   private final Project.NameKey parentProjectName;
+
   protected String message;
+
+  private List<AccessSection> sectionList;
   private boolean checkIfOwner;
+  private Boolean canWriteConfig;
 
   protected ProjectAccessHandler(
-      ProjectControl.Factory projectControlFactory,
       GroupBackend groupBackend,
       MetaDataUpdate.User metaDataUpdateFactory,
       AllProjectsName allProjects,
       Provider<SetParent> setParent,
+      CurrentUser user,
       Project.NameKey projectName,
       ObjectId base,
       List<AccessSection> sectionList,
       Project.NameKey parentProjectName,
       String message,
       ContributorAgreementsChecker contributorAgreements,
+      PermissionBackend permissionBackend,
       boolean checkIfOwner) {
-    this.projectControlFactory = projectControlFactory;
     this.groupBackend = groupBackend;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allProjects = allProjects;
     this.setParent = setParent;
+    this.user = user;
 
     this.projectName = projectName;
     this.base = base;
@@ -92,6 +101,7 @@
     this.parentProjectName = parentProjectName;
     this.message = message;
     this.contributorAgreements = contributorAgreements;
+    this.permissionBackend = permissionBackend;
     this.checkIfOwner = checkIfOwner;
   }
 
@@ -100,10 +110,8 @@
       throws NoSuchProjectException, IOException, ConfigInvalidException, InvalidNameException,
           NoSuchGroupException, OrmException, UpdateParentFailedException,
           PermissionDeniedException, PermissionBackendException {
-    final ProjectControl projectControl = projectControlFactory.controlFor(projectName);
-
     try {
-      contributorAgreements.check(projectName, projectControl.getUser());
+      contributorAgreements.check(projectName, user);
     } catch (AuthException e) {
       throw new PermissionDeniedException(e.getMessage());
     }
@@ -111,18 +119,19 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
       ProjectConfig config = ProjectConfig.read(md, base);
       Set<String> toDelete = scanSectionNames(config);
+      PermissionBackend.ForProject forProject = permissionBackend.user(user).project(projectName);
 
       for (AccessSection section : mergeSections(sectionList)) {
         String name = section.getName();
 
         if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
-          if (checkIfOwner && !projectControl.isOwner()) {
+          if (checkIfOwner && !canWriteConfig()) {
             continue;
           }
           replace(config, toDelete, section);
 
         } else if (AccessSection.isValid(name)) {
-          if (checkIfOwner && !projectControl.controlForRef(name).isOwner()) {
+          if (checkIfOwner && !forProject.ref(name).test(RefPermission.WRITE_CONFIG)) {
             continue;
           }
 
@@ -134,11 +143,11 @@
 
       for (String name : toDelete) {
         if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
-          if (!checkIfOwner || projectControl.isOwner()) {
+          if (!checkIfOwner || canWriteConfig()) {
             config.remove(config.getAccessSection(name));
           }
 
-        } else if (!checkIfOwner || projectControl.controlForRef(name).isOwner()) {
+        } else if (!checkIfOwner || forProject.ref(name).test(RefPermission.WRITE_CONFIG)) {
           config.remove(config.getAccessSection(name));
         }
       }
@@ -151,8 +160,8 @@
           setParent
               .get()
               .validateParentUpdate(
-                  projectControl.getProject().getNameKey(),
-                  projectControl.getUser().asIdentifiedUser(),
+                  projectName,
+                  user.asIdentifiedUser(),
                   MoreObjects.firstNonNull(parentProjectName, allProjects).get(),
                   checkIfOwner);
         } catch (AuthException e) {
@@ -176,17 +185,14 @@
         md.setMessage("Modify access rules\n");
       }
 
-      return updateProjectConfig(projectControl, config, md, parentProjectUpdate);
+      return updateProjectConfig(config, md, parentProjectUpdate);
     } catch (RepositoryNotFoundException notFound) {
       throw new NoSuchProjectException(projectName);
     }
   }
 
   protected abstract T updateProjectConfig(
-      ProjectControl projectControl,
-      ProjectConfig config,
-      MetaDataUpdate md,
-      boolean parentProjectUpdate)
+      ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
       throws IOException, NoSuchProjectException, ConfigInvalidException, OrmException,
           PermissionDeniedException, PermissionBackendException;
 
@@ -219,4 +225,20 @@
       ref.setUUID(group.getUUID());
     }
   }
+
+  /** Provide a local cache for {@code ProjectPermission.WRITE_CONFIG} capability. */
+  private boolean canWriteConfig() throws PermissionBackendException {
+    checkNotNull(user);
+
+    if (canWriteConfig != null) {
+      return canWriteConfig;
+    }
+    try {
+      permissionBackend.user(user).project(projectName).check(ProjectPermission.WRITE_CONFIG);
+      canWriteConfig = true;
+    } catch (AuthException e) {
+      canWriteConfig = false;
+    }
+    return canWriteConfig;
+  }
 }
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 f27b9d3..e7e0021 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
@@ -30,6 +30,7 @@
 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.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeInserter;
@@ -42,10 +43,10 @@
 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.ProjectPermission;
 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.SetParent;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
@@ -82,7 +83,6 @@
 
   @Inject
   ReviewProjectAccess(
-      final ProjectControl.Factory projectControlFactory,
       PermissionBackend permissionBackend,
       GroupBackend groupBackend,
       MetaDataUpdate.User metaDataUpdateFactory,
@@ -96,23 +96,25 @@
       Provider<SetParent> setParent,
       Sequences seq,
       ContributorAgreementsChecker contributorAgreements,
+      Provider<CurrentUser> user,
       @Assisted("projectName") Project.NameKey projectName,
       @Nullable @Assisted ObjectId base,
       @Assisted List<AccessSection> sectionList,
       @Nullable @Assisted("parentProjectName") Project.NameKey parentProjectName,
       @Nullable @Assisted String message) {
     super(
-        projectControlFactory,
         groupBackend,
         metaDataUpdateFactory,
         allProjects,
         setParent,
+        user.get(),
         projectName,
         base,
         sectionList,
         parentProjectName,
         message,
         contributorAgreements,
+        permissionBackend,
         false);
     this.db = db;
     this.permissionBackend = permissionBackend;
@@ -129,27 +131,16 @@
   @SuppressWarnings("deprecation")
   @Override
   protected Change.Id updateProjectConfig(
-      ProjectControl projectControl,
-      ProjectConfig config,
-      MetaDataUpdate md,
-      boolean parentProjectUpdate)
+      ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
       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) {
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(config.getName());
+    if (!check(perm, ProjectPermission.READ_CONFIG)) {
       throw new PermissionDeniedException(RefNames.REFS_CONFIG + " not visible");
     }
-    if (!projectControl.isOwner()) {
-      try {
-        metaRef.check(RefPermission.CREATE_CHANGE);
-      } catch (AuthException denied) {
-        throw new PermissionDeniedException("cannot create change for " + RefNames.REFS_CONFIG);
-      }
+
+    if (!check(perm, ProjectPermission.WRITE_CONFIG)
+        && !check(perm.ref(RefNames.REFS_CONFIG), RefPermission.CREATE_CHANGE)) {
+      throw new PermissionDeniedException("cannot create change for " + RefNames.REFS_CONFIG);
     }
 
     md.setInsertChangeId(true);
@@ -165,8 +156,7 @@
         ObjectReader objReader = objInserter.newReader();
         RevWalk rw = new RevWalk(objReader);
         BatchUpdate bu =
-            updateFactory.create(
-                db, config.getProject().getNameKey(), projectControl.getUser(), TimeUtil.nowTs())) {
+            updateFactory.create(db, config.getProject().getNameKey(), user, TimeUtil.nowTs())) {
       bu.setRepository(md.getRepository(), rw, objInserter);
       bu.insertChange(
           changeInserterFactory
@@ -223,4 +213,24 @@
       }
     }
   }
+
+  private boolean check(PermissionBackend.ForRef perm, RefPermission p)
+      throws PermissionBackendException {
+    try {
+      perm.check(p);
+      return true;
+    } catch (AuthException denied) {
+      return false;
+    }
+  }
+
+  private boolean check(PermissionBackend.ForProject perm, ProjectPermission p)
+      throws PermissionBackendException {
+    try {
+      perm.check(p);
+      return true;
+    } catch (AuthException denied) {
+      return false;
+    }
+  }
 }
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 51c60af..b22a2a4 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
@@ -21,7 +21,7 @@
  * @param staticResourcePath
  * @param? versionInfo
  */
-{template .Index autoescape="strict" kind="html"}
+{template .Index}
   <!DOCTYPE html>{\n}
   <html lang="en">{\n}
   <meta charset="utf-8">{\n}
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
index 18256c6..6dd15bc 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -36,9 +36,11 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.attribute.FileTime;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.zip.GZIPInputStream;
-import org.joda.time.format.ISODateTimeFormat;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -91,7 +93,12 @@
   @Before
   public void setUp() {
     fs = Jimfs.newFileSystem(Configuration.unix());
-    ts = new AtomicLong(ISODateTimeFormat.dateTime().parseMillis("2010-01-30T12:00:00.000-08:00"));
+    ts =
+        new AtomicLong(
+            LocalDateTime.of(2010, Month.JANUARY, 30, 12, 0, 0)
+                .atOffset(ZoneOffset.ofHours(-8))
+                .toInstant()
+                .toEpochMilli());
   }
 
   @Test
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
index b53b59b..b5b36f1 100644
--- a/gerrit-index/src/main/java/com/google/gerrit/index/IndexConfig.java
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/IndexConfig.java
@@ -61,15 +61,15 @@
   public abstract static class Builder {
     public abstract Builder maxLimit(int maxLimit);
 
-    abstract int maxLimit();
+    public abstract int maxLimit();
 
     public abstract Builder maxPages(int maxPages);
 
-    abstract int maxPages();
+    public abstract int maxPages();
 
     public abstract Builder maxTerms(int maxTerms);
 
-    abstract int maxTerms();
+    public abstract int maxTerms();
 
     public abstract Builder separateChangeSubIndexes(boolean separate);
 
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 d738540..d5d6360 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
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.project.ProjectIndex;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
@@ -85,7 +86,10 @@
         new FactoryModuleBuilder()
             .implement(GroupIndex.class, LuceneGroupIndex.class)
             .build(GroupIndex.Factory.class));
-
+    install(
+        new FactoryModuleBuilder()
+            .implement(ProjectIndex.class, LuceneProjectIndex.class)
+            .build(ProjectIndex.Factory.class));
     install(new IndexModule(threads));
     if (singleVersions == null) {
       install(new MultiVersionModule());
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneProjectIndex.java
new file mode 100644
index 0000000..6354f61
--- /dev/null
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -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.
+
+package com.google.gerrit.lucene;
+
+import static com.google.gerrit.server.index.project.ProjectField.NAME;
+
+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.Project;
+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.project.ProjectIndex;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectData;
+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 java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.SearcherFactory;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.search.TopFieldDocs;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import org.apache.lucene.store.RAMDirectory;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class LuceneProjectIndex extends AbstractLuceneIndex<Project.NameKey, ProjectData>
+    implements ProjectIndex {
+  private static final Logger log = LoggerFactory.getLogger(LuceneProjectIndex.class);
+
+  private static final String PROJECTS = "projects";
+
+  private static final String NAME_SORT_FIELD = sortFieldName(NAME);
+
+  private static Term idTerm(ProjectData projectState) {
+    return idTerm(projectState.getProject().getNameKey());
+  }
+
+  private static Term idTerm(Project.NameKey nameKey) {
+    return QueryBuilder.stringTerm(NAME.getName(), nameKey.get());
+  }
+
+  private final GerritIndexWriterConfig indexWriterConfig;
+  private final QueryBuilder<ProjectData> queryBuilder;
+  private final Provider<ProjectCache> projectCache;
+
+  private static Directory dir(Schema<ProjectData> schema, Config cfg, SitePaths sitePaths)
+      throws IOException {
+    if (LuceneIndexModule.isInMemoryTest(cfg)) {
+      return new RAMDirectory();
+    }
+    Path indexDir = LuceneVersionManager.getDir(sitePaths, PROJECTS, schema);
+    return FSDirectory.open(indexDir);
+  }
+
+  @Inject
+  LuceneProjectIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      Provider<ProjectCache> projectCache,
+      @Assisted Schema<ProjectData> schema)
+      throws IOException {
+    super(
+        schema,
+        sitePaths,
+        dir(schema, cfg, sitePaths),
+        PROJECTS,
+        null,
+        new GerritIndexWriterConfig(cfg, PROJECTS),
+        new SearcherFactory());
+    this.projectCache = projectCache;
+
+    indexWriterConfig = new GerritIndexWriterConfig(cfg, PROJECTS);
+    queryBuilder = new QueryBuilder<>(schema, indexWriterConfig.getAnalyzer());
+  }
+
+  @Override
+  public void replace(ProjectData projectState) throws IOException {
+    try {
+      replace(idTerm(projectState), toDocument(projectState)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public void delete(Project.NameKey nameKey) throws IOException {
+    try {
+      delete(idTerm(nameKey)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public DataSource<ProjectData> getSource(Predicate<ProjectData> p, QueryOptions opts)
+      throws QueryParseException {
+    return new QuerySource(
+        opts,
+        queryBuilder.toQuery(p),
+        new Sort(new SortField(NAME_SORT_FIELD, SortField.Type.STRING, false)));
+  }
+
+  private class QuerySource implements DataSource<ProjectData> {
+    private final QueryOptions opts;
+    private final Query query;
+    private final Sort sort;
+
+    private QuerySource(QueryOptions opts, Query query, Sort sort) {
+      this.opts = opts;
+      this.query = query;
+      this.sort = sort;
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<ProjectData> read() throws OrmException {
+      IndexSearcher searcher = null;
+      try {
+        searcher = acquire();
+        int realLimit = opts.start() + opts.limit();
+        TopFieldDocs docs = searcher.search(query, realLimit, sort);
+        List<ProjectData> 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.projectFields(opts));
+          result.add(toProjectData(doc));
+        }
+        final List<ProjectData> r = Collections.unmodifiableList(result);
+        return new ResultSet<ProjectData>() {
+          @Override
+          public Iterator<ProjectData> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<ProjectData> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      } finally {
+        if (searcher != null) {
+          try {
+            release(searcher);
+          } catch (IOException e) {
+            log.warn("cannot release Lucene searcher", e);
+          }
+        }
+      }
+    }
+  }
+
+  private ProjectData toProjectData(Document doc) {
+    Project.NameKey nameKey = new Project.NameKey(doc.getField(NAME.getName()).stringValue());
+    return projectCache.get().get(nameKey).toProjectData();
+  }
+}
diff --git a/gerrit-pgm/BUILD b/gerrit-pgm/BUILD
index 1fd3165..60663d7 100644
--- a/gerrit-pgm/BUILD
+++ b/gerrit-pgm/BUILD
@@ -22,7 +22,6 @@
     "//lib/guice:guice-assistedinject",
     "//lib/guice:guice-servlet",
     "//lib/jgit/org.eclipse.jgit:jgit",
-    "//lib/joda:joda-time",
     "//lib/log:api",
     "//lib/log:log4j",
 ]
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 6b5c157..4c44ef3 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
@@ -66,6 +66,7 @@
 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.LocalMergeSuperSetComputation;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.receive.ReceiveCommitsExecutorModule;
@@ -84,6 +85,7 @@
 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.project.DefaultProjectNameLockManager;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
@@ -472,6 +474,8 @@
     if (testSysModule != null) {
       modules.add(testSysModule);
     }
+    modules.add(new LocalMergeSuperSetComputation.Module());
+    modules.add(new DefaultProjectNameLockManager.Module());
     return cfgInjector.createChildInjector(modules);
   }
 
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 9a81c52..453f871 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
@@ -90,12 +90,12 @@
     this.dbFactory = dbFactory;
   }
 
-  @Inject(optional = true)
+  @Inject
   void set(AccountIndexCollection accountIndexCollection) {
     this.accountIndexCollection = accountIndexCollection;
   }
 
-  @Inject(optional = true)
+  @Inject
   void set(GroupIndexCollection groupIndexCollection) {
     this.groupIndexCollection = groupIndexCollection;
   }
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-plugin-api/BUILD b/gerrit-plugin-api/BUILD
index fe9ce19..4f846b5 100644
--- a/gerrit-plugin-api/BUILD
+++ b/gerrit-plugin-api/BUILD
@@ -29,7 +29,6 @@
     "//lib/httpcomponents:httpcore",
     "//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet",
     "//lib/jgit/org.eclipse.jgit:jgit",
-    "//lib/joda:joda-time",
     "//lib/log:api",
     "//lib/log:log4j",
     "//lib/mina:sshd",
@@ -49,7 +48,6 @@
     "//lib:protobuf",
     "//lib:servlet-api-3_1-without-neverlink",
     "//lib:soy",
-    "//lib:velocity",
 ]
 
 java_binary(
diff --git a/gerrit-server/BUILD b/gerrit-server/BUILD
index e124e89..e0ce904 100644
--- a/gerrit-server/BUILD
+++ b/gerrit-server/BUILD
@@ -76,7 +76,6 @@
         "//lib:servlet-api-3_1",
         "//lib:soy",
         "//lib:tukaani-xz",
-        "//lib:velocity",
         "//lib/auto:auto-value",
         "//lib/bouncycastle:bcpkix-neverlink",
         "//lib/bouncycastle:bcprov-neverlink",
@@ -92,7 +91,6 @@
         "//lib/guice:guice-servlet",
         "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
         "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/joda:joda-time",
         "//lib/jsoup",
         "//lib/log:api",
         "//lib/log:jsonevent-layout",
@@ -122,7 +120,6 @@
         "//lib:blame-cache",
         "//lib:guava",
         "//lib:soy",
-        "//lib:velocity",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
     ],
@@ -181,7 +178,6 @@
     "//lib/guice:guice-servlet",
     "//lib/jgit/org.eclipse.jgit:jgit",
     "//lib/jgit/org.eclipse.jgit.junit:junit",
-    "//lib/joda:joda-time",
     "//lib/log:api",
     "//lib/log:impl_log4j",
     "//lib/log:log4j",
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 6971b48..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
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.git.LabelNormalizer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-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;
@@ -213,7 +212,7 @@
         }
       }
       return labelNormalizer.normalize(notes, user, byUser.values()).getNormalized();
-    } catch (IOException | PermissionBackendException e) {
+    } catch (IOException e) {
       throw new OrmException(e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CmdLineParserModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/CmdLineParserModule.java
index 63f7202..d7f6e30 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CmdLineParserModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CmdLineParserModule.java
@@ -25,10 +25,10 @@
 import com.google.gerrit.server.args4j.ChangeIdHandler;
 import com.google.gerrit.server.args4j.ObjectIdHandler;
 import com.google.gerrit.server.args4j.PatchSetIdHandler;
-import com.google.gerrit.server.args4j.ProjectControlHandler;
+import com.google.gerrit.server.args4j.ProjectHandler;
 import com.google.gerrit.server.args4j.SocketAddressHandler;
 import com.google.gerrit.server.args4j.TimestampHandler;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.OptionHandlerUtil;
 import com.google.gerrit.util.cli.OptionHandlers;
@@ -51,7 +51,7 @@
     registerOptionHandler(Change.Id.class, ChangeIdHandler.class);
     registerOptionHandler(ObjectId.class, ObjectIdHandler.class);
     registerOptionHandler(PatchSet.Id.class, PatchSetIdHandler.class);
-    registerOptionHandler(ProjectControl.class, ProjectControlHandler.class);
+    registerOptionHandler(ProjectState.class, ProjectHandler.class);
     registerOptionHandler(SocketAddress.class, SocketAddressHandler.class);
     registerOptionHandler(Timestamp.class, TimestampHandler.class);
   }
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 ee25d54..2971037 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
@@ -111,8 +111,7 @@
   private final AccountQueryBuilder accountQueryBuilder;
   private final Provider<AccountQueryProcessor> queryProvider;
   private final GroupBackend groupBackend;
-  private final GroupMembers.Factory groupMembersFactory;
-  private final Provider<CurrentUser> currentUser;
+  private final GroupMembers groupMembers;
   private final ReviewerRecommender reviewerRecommender;
   private final Metrics metrics;
 
@@ -122,8 +121,7 @@
       AccountQueryBuilder accountQueryBuilder,
       Provider<AccountQueryProcessor> queryProvider,
       GroupBackend groupBackend,
-      GroupMembers.Factory groupMembersFactory,
-      Provider<CurrentUser> currentUser,
+      GroupMembers groupMembers,
       ReviewerRecommender reviewerRecommender,
       Metrics metrics) {
     Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
@@ -131,9 +129,8 @@
     this.accountLoader = accountLoaderFactory.create(fillOptions);
     this.accountQueryBuilder = accountQueryBuilder;
     this.queryProvider = queryProvider;
-    this.currentUser = currentUser;
     this.groupBackend = groupBackend;
-    this.groupMembersFactory = groupMembersFactory;
+    this.groupMembers = groupMembers;
     this.reviewerRecommender = reviewerRecommender;
     this.metrics = metrics;
   }
@@ -303,10 +300,7 @@
     }
 
     try {
-      Set<Account> members =
-          groupMembersFactory
-              .create(currentUser.get())
-              .listAccounts(group.getUUID(), project.getNameKey());
+      Set<Account> members = groupMembers.listAccounts(group.getUUID(), project.getNameKey());
 
       if (members.isEmpty()) {
         return result;
@@ -330,9 +324,7 @@
           return result;
         }
       }
-    } catch (NoSuchGroupException e) {
-      return result;
-    } catch (NoSuchProjectException e) {
+    } catch (NoSuchGroupException | NoSuchProjectException e) {
       return result;
     }
 
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 12bd8ff..7971d30 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
@@ -420,7 +420,7 @@
   public static Set<Integer> getStarredPatchSets(Set<String> labels, String label) {
     return labels
         .stream()
-        .filter(l -> l.startsWith(label))
+        .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());
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 1c5495f..5680b56 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
@@ -19,6 +19,7 @@
 import com.google.common.io.ByteSource;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.extensions.api.accounts.SshKeyInput;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -28,7 +29,6 @@
 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.AddSshKey.Input;
 import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -45,13 +45,9 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class AddSshKey implements RestModifyView<AccountResource, Input> {
+public class AddSshKey implements RestModifyView<AccountResource, SshKeyInput> {
   private static final Logger log = LoggerFactory.getLogger(AddSshKey.class);
 
-  public static class Input {
-    public RawInput raw;
-  }
-
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
@@ -73,7 +69,7 @@
   }
 
   @Override
-  public Response<SshKeyInfo> apply(AccountResource rsrc, Input input)
+  public Response<SshKeyInfo> apply(AccountResource rsrc, SshKeyInput input)
       throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException,
           PermissionBackendException {
     if (self.get() != rsrc.getUser()) {
@@ -82,10 +78,10 @@
     return apply(rsrc.getUser(), input);
   }
 
-  public Response<SshKeyInfo> apply(IdentifiedUser user, Input input)
+  public Response<SshKeyInfo> apply(IdentifiedUser user, SshKeyInput input)
       throws BadRequestException, IOException, ConfigInvalidException {
     if (input == null) {
-      input = new Input();
+      input = new SshKeyInput();
     }
     if (input.raw == null) {
       throw new BadRequestException("SSH public key missing");
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 43669c0..4b3bf39 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
@@ -16,12 +16,12 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.Input;
 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.IdentifiedUser;
-import com.google.gerrit.server.account.DeleteActive.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -32,7 +32,6 @@
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 @Singleton
 public class DeleteActive implements RestModifyView<AccountResource, Input> {
-  public static class Input {}
 
   private final Provider<IdentifiedUser> self;
   private final SetInactiveFlag setInactiveFlag;
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 aec3a14..cccac63 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
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -25,7 +26,6 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 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;
@@ -41,7 +41,6 @@
 
 @Singleton
 public class DeleteEmail implements RestModifyView<AccountResource.Email, Input> {
-  public static class Input {}
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
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 f1ecd29..8dec7d9 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
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.DeleteSshKey.Input;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -33,7 +33,6 @@
 
 @Singleton
 public class DeleteSshKey implements RestModifyView<AccountResource.SshKey, Input> {
-  public static class Input {}
 
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
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 4dc960d..f1f688c 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
@@ -21,15 +21,14 @@
 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.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;
+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.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashSet;
@@ -37,28 +36,22 @@
 import java.util.Set;
 
 public class GroupMembers {
-  public interface Factory {
-    GroupMembers create(CurrentUser currentUser);
-  }
 
   private final GroupCache groupCache;
   private final GroupControl.Factory groupControlFactory;
   private final AccountCache accountCache;
-  private final ProjectControl.GenericFactory projectControl;
-  private final CurrentUser currentUser;
+  private final ProjectCache projectCache;
 
   @Inject
   GroupMembers(
       GroupCache groupCache,
       GroupControl.Factory groupControlFactory,
       AccountCache accountCache,
-      ProjectControl.GenericFactory projectControl,
-      @Assisted CurrentUser currentUser) {
+      ProjectCache projectCache) {
     this.groupCache = groupCache;
     this.groupControlFactory = groupControlFactory;
     this.accountCache = accountCache;
-    this.projectControl = projectControl;
-    this.currentUser = currentUser;
+    this.projectCache = projectCache;
   }
 
   public Set<Account> listAccounts(AccountGroup.UUID groupUUID, Project.NameKey project)
@@ -88,11 +81,13 @@
       return Collections.emptySet();
     }
 
-    final Iterable<AccountGroup.UUID> ownerGroups =
-        projectControl.controlFor(project, currentUser).getProjectState().getAllOwners();
+    ProjectState projectState = projectCache.checkedGet(project);
+    if (projectState == null) {
+      throw new NoSuchProjectException(project);
+    }
 
     final HashSet<Account> projectOwners = new HashSet<>();
-    for (AccountGroup.UUID ownerGroup : ownerGroups) {
+    for (AccountGroup.UUID ownerGroup : projectState.getAllOwners()) {
       if (!seen.contains(ownerGroup)) {
         projectOwners.addAll(listAccounts(ownerGroup, project, seen));
       }
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 ecc6b8c..8436d1d 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
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.Index.Input;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -29,7 +29,6 @@
 
 @Singleton
 public class Index implements RestModifyView<AccountResource, Input> {
-  public static class Input {}
 
   private final AccountCache accountCache;
   private final PermissionBackend permissionBackend;
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 7ce2ea8..cbfa172 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
@@ -16,10 +16,10 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.Input;
 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.server.account.PutActive.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -29,7 +29,6 @@
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 @Singleton
 public class PutActive implements RestModifyView<AccountResource, Input> {
-  public static class Input {}
 
   private final SetInactiveFlag setInactiveFlag;
 
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 e00f6b3..5005212 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
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.common.HttpPasswordInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -24,7 +25,6 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 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;
@@ -40,12 +40,7 @@
 import org.apache.commons.codec.binary.Base64;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
-public class PutHttpPassword implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    public String httpPassword;
-    public boolean generate;
-  }
-
+public class PutHttpPassword implements RestModifyView<AccountResource, HttpPasswordInput> {
   private static final int LEN = 31;
   private static final SecureRandom rng;
 
@@ -75,7 +70,7 @@
   }
 
   @Override
-  public Response<String> apply(AccountResource rsrc, Input input)
+  public Response<String> apply(AccountResource rsrc, HttpPasswordInput input)
       throws AuthException, ResourceNotFoundException, ResourceConflictException, OrmException,
           IOException, ConfigInvalidException, PermissionBackendException {
     if (self.get() != rsrc.getUser()) {
@@ -83,7 +78,7 @@
     }
 
     if (input == null) {
-      input = new Input();
+      input = new HttpPasswordInput();
     }
     input.httpPassword = Strings.emptyToNull(input.httpPassword);
 
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 7537230..0ac9d1d 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
@@ -16,8 +16,8 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.common.NameInput;
 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.Response;
@@ -25,7 +25,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.PutName.Input;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -37,11 +36,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class PutName implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    @DefaultInput public String name;
-  }
-
+public class PutName implements RestModifyView<AccountResource, NameInput> {
   private final Provider<CurrentUser> self;
   private final Realm realm;
   private final PermissionBackend permissionBackend;
@@ -60,7 +55,7 @@
   }
 
   @Override
-  public Response<String> apply(AccountResource rsrc, Input input)
+  public Response<String> apply(AccountResource rsrc, NameInput input)
       throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
           IOException, PermissionBackendException, ConfigInvalidException {
     if (self.get() != rsrc.getUser()) {
@@ -69,11 +64,11 @@
     return apply(rsrc.getUser(), input);
   }
 
-  public Response<String> apply(IdentifiedUser user, Input input)
+  public Response<String> apply(IdentifiedUser user, NameInput input)
       throws MethodNotAllowedException, ResourceNotFoundException, IOException,
           ConfigInvalidException {
     if (input == null) {
-      input = new Input();
+      input = new NameInput();
     }
 
     if (!realm.allowsEdit(AccountFieldName.FULL_NAME)) {
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 b3f8fc5..5f9ddee 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
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -21,7 +22,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.PutPreferred.Input;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -35,7 +35,6 @@
 
 @Singleton
 public class PutPreferred implements RestModifyView<AccountResource.Email, Input> {
-  static class Input {}
 
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
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 1df67c3..35ece15 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
@@ -15,15 +15,14 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.accounts.StatusInput;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
 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.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.PutStatus.Input;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -35,17 +34,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class PutStatus implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    @DefaultInput String status;
-
-    public Input(String status) {
-      this.status = status;
-    }
-
-    public Input() {}
-  }
-
+public class PutStatus implements RestModifyView<AccountResource, StatusInput> {
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
   private final AccountsUpdate.Server accountsUpdate;
@@ -61,7 +50,7 @@
   }
 
   @Override
-  public Response<String> apply(AccountResource rsrc, Input input)
+  public Response<String> apply(AccountResource rsrc, StatusInput input)
       throws AuthException, ResourceNotFoundException, OrmException, IOException,
           PermissionBackendException, ConfigInvalidException {
     if (self.get() != rsrc.getUser()) {
@@ -70,10 +59,10 @@
     return apply(rsrc.getUser(), input);
   }
 
-  public Response<String> apply(IdentifiedUser user, Input input)
+  public Response<String> apply(IdentifiedUser user, StatusInput input)
       throws ResourceNotFoundException, IOException, ConfigInvalidException {
     if (input == null) {
-      input = new Input();
+      input = new StatusInput();
     }
 
     String newStatus = input.status;
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 a73bdd9..2368913 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
@@ -15,15 +15,14 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
+import com.google.gerrit.extensions.api.accounts.UsernameInput;
 import com.google.gerrit.extensions.client.AccountFieldName;
 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.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 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;
@@ -35,11 +34,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class PutUsername implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    @DefaultInput public String username;
-  }
-
+public class PutUsername implements RestModifyView<AccountResource, UsernameInput> {
   private final Provider<CurrentUser> self;
   private final ChangeUserName.Factory changeUserNameFactory;
   private final PermissionBackend permissionBackend;
@@ -58,7 +53,7 @@
   }
 
   @Override
-  public String apply(AccountResource rsrc, Input input)
+  public String apply(AccountResource rsrc, UsernameInput input)
       throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
           ResourceConflictException, OrmException, IOException, ConfigInvalidException,
           PermissionBackendException {
@@ -71,7 +66,7 @@
     }
 
     if (input == null) {
-      input = new Input();
+      input = new UsernameInput();
     }
 
     try {
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 f8539d9..f36322c 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
@@ -21,6 +21,8 @@
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
+import com.google.gerrit.extensions.api.accounts.SshKeyInput;
+import com.google.gerrit.extensions.api.accounts.StatusInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
@@ -34,6 +36,7 @@
 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.Input;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
@@ -219,9 +222,9 @@
   public void setActive(boolean active) throws RestApiException {
     try {
       if (active) {
-        putActive.apply(account, new PutActive.Input());
+        putActive.apply(account, new Input());
       } else {
-        deleteActive.apply(account, new DeleteActive.Input());
+        deleteActive.apply(account, new Input());
       }
     } catch (Exception e) {
       throw asRestApiException("Cannot set active", e);
@@ -404,7 +407,7 @@
 
   @Override
   public void setStatus(String status) throws RestApiException {
-    PutStatus.Input in = new PutStatus.Input(status);
+    StatusInput in = new StatusInput(status);
     try {
       putStatus.apply(account, in);
     } catch (Exception e) {
@@ -423,7 +426,7 @@
 
   @Override
   public SshKeyInfo addSshKey(String key) throws RestApiException {
-    AddSshKey.Input in = new AddSshKey.Input();
+    SshKeyInput in = new SshKeyInput();
     in.raw = RawInputUtil.create(key);
     try {
       return addSshKey.apply(account, in).value();
@@ -490,7 +493,7 @@
   @Override
   public void index() throws RestApiException {
     try {
-      index.apply(account, new Index.Input());
+      index.apply(account, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot index account", 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 0fba74a..701384b 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
@@ -35,12 +35,14 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
+import com.google.gerrit.extensions.api.changes.TopicInput;
 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.Input;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
@@ -435,7 +437,7 @@
 
   @Override
   public void topic(String topic) throws RestApiException {
-    PutTopic.Input in = new PutTopic.Input();
+    TopicInput in = new TopicInput();
     in.topic = topic;
     try {
       putTopic.apply(change, in);
@@ -646,7 +648,7 @@
   @Override
   public void index() throws RestApiException {
     try {
-      index.apply(change, new Index.Input());
+      index.apply(change, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot index change", e);
     }
@@ -658,9 +660,9 @@
     // StarredChangesUtil.
     try {
       if (ignore) {
-        this.ignore.apply(change, new Ignore.Input());
+        this.ignore.apply(change, new Input());
       } else {
-        unignore.apply(change, new Unignore.Input());
+        unignore.apply(change, new Input());
       }
     } catch (OrmException | IllegalLabelException e) {
       throw asRestApiException("Cannot ignore change", e);
@@ -682,9 +684,9 @@
     // StarredChangesUtil.
     try {
       if (reviewed) {
-        markAsReviewed.apply(change, new MarkAsReviewed.Input());
+        markAsReviewed.apply(change, new Input());
       } else {
-        markAsUnreviewed.apply(change, new MarkAsUnreviewed.Input());
+        markAsUnreviewed.apply(change, new Input());
       }
     } catch (OrmException | IllegalLabelException e) {
       throw asRestApiException(
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 d1b57e6..823e771 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
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -106,7 +107,7 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      deleteChangeEdit.apply(changeResource, new DeleteChangeEdit.Input());
+      deleteChangeEdit.apply(changeResource, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot delete change edit", 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 65bbc47..7ecfce7 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
@@ -36,8 +36,10 @@
 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.DescriptionInput;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
@@ -300,13 +302,13 @@
   @Override
   public void setReviewed(String path, boolean reviewed) throws RestApiException {
     try {
-      RestModifyView<FileResource, Reviewed.Input> view;
+      RestModifyView<FileResource, Input> view;
       if (reviewed) {
         view = putReviewed;
       } else {
         view = deleteReviewed;
       }
-      view.apply(files.parse(revision, IdString.fromDecoded(path)), new Reviewed.Input());
+      view.apply(files.parse(revision, IdString.fromDecoded(path)), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot update reviewed flag", e);
     }
@@ -565,7 +567,7 @@
 
   @Override
   public void description(String description) throws RestApiException {
-    PutDescription.Input in = new PutDescription.Input();
+    DescriptionInput in = new DescriptionInput();
     in.description = description;
     try {
       putDescription.apply(revision, in);
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 42213f7..28cc60f 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
@@ -17,10 +17,14 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.api.groups.GroupApi;
+import com.google.gerrit.extensions.api.groups.OwnerInput;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.DescriptionInput;
 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.common.Input;
+import com.google.gerrit.extensions.common.NameInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.group.AddMembers;
 import com.google.gerrit.server.group.AddSubgroups;
@@ -138,7 +142,7 @@
 
   @Override
   public void name(String name) throws RestApiException {
-    PutName.Input in = new PutName.Input();
+    NameInput in = new NameInput();
     in.name = name;
     try {
       putName.apply(rsrc, in);
@@ -158,7 +162,7 @@
 
   @Override
   public void owner(String owner) throws RestApiException {
-    PutOwner.Input in = new PutOwner.Input();
+    OwnerInput in = new OwnerInput();
     in.owner = owner;
     try {
       putOwner.apply(rsrc, in);
@@ -174,7 +178,7 @@
 
   @Override
   public void description(String description) throws RestApiException {
-    PutDescription.Input in = new PutDescription.Input();
+    DescriptionInput in = new DescriptionInput();
     in.description = description;
     try {
       putDescription.apply(rsrc, in);
@@ -269,7 +273,7 @@
   @Override
   public void index() throws RestApiException {
     try {
-      index.apply(rsrc, new Index.Input());
+      index.apply(rsrc, new Input());
     } 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 e1e72ba..f439f7d 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
@@ -34,6 +34,7 @@
 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.ProjectResource;
 import com.google.gerrit.server.project.ProjectsCollection;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -119,7 +120,8 @@
 
     for (String project : req.getProjects()) {
       try {
-        list.addProject(projects.parse(tlr, IdString.fromDecoded(project)).getControl());
+        ProjectResource rsrc = projects.parse(tlr, IdString.fromDecoded(project));
+        list.addProject(rsrc.getProjectState());
       } catch (Exception e) {
         throw asRestApiException("Error looking up project " + project, e);
       }
@@ -131,6 +133,10 @@
 
     list.setVisibleToAll(req.getVisibleToAll());
 
+    if (req.getOwnedBy() != null) {
+      list.setOwnedBy(req.getOwnedBy());
+    }
+
     if (req.getUser() != null) {
       try {
         list.setUser(accounts.parse(req.getUser()).getAccountId());
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
index 2fc2e50..71f7832 100644
--- 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
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.api.plugins;
 
 import com.google.gerrit.extensions.api.plugins.PluginApi;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.plugins.DisablePlugin;
@@ -57,16 +58,16 @@
 
   @Override
   public void enable() throws RestApiException {
-    enable.apply(resource, new EnablePlugin.Input());
+    enable.apply(resource, new Input());
   }
 
   @Override
   public void disable() throws RestApiException {
-    disable.apply(resource, new DisablePlugin.Input());
+    disable.apply(resource, new Input());
   }
 
   @Override
   public void reload() throws RestApiException {
-    reload.apply(resource, new ReloadPlugin.Input());
+    reload.apply(resource, new Input());
   }
 }
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 642791a..aee9b3f 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
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -98,7 +99,7 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      deleteBranch.apply(resource(), new DeleteBranch.Input());
+      deleteBranch.apply(resource(), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot delete branch", 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
index 0d4afd6..12c4244 100644
--- 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
@@ -79,7 +79,8 @@
     SetDashboardInput input = new SetDashboardInput();
     input.id = id;
     try {
-      set.apply(DashboardResource.projectDefault(project.getControl()), input);
+      set.apply(
+          DashboardResource.projectDefault(project.getProjectState(), project.getUser()), input);
     } catch (Exception e) {
       String msg = String.format("Cannot %s default dashboard", id != null ? "set" : "remove");
       throw asRestApiException(msg, e);
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 9fd4d48..fae5bba 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
@@ -33,6 +33,8 @@
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
+import com.google.gerrit.extensions.api.projects.HeadInput;
+import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.api.projects.TagApi;
@@ -59,6 +61,8 @@
 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.GetParent;
 import com.google.gerrit.server.project.ListBranches;
 import com.google.gerrit.server.project.ListChildProjects;
 import com.google.gerrit.server.project.ListDashboards;
@@ -69,6 +73,8 @@
 import com.google.gerrit.server.project.PutConfig;
 import com.google.gerrit.server.project.PutDescription;
 import com.google.gerrit.server.project.SetAccess;
+import com.google.gerrit.server.project.SetHead;
+import com.google.gerrit.server.project.SetParent;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -110,6 +116,10 @@
   private final DashboardApiImpl.Factory dashboardApi;
   private final CheckAccess checkAccess;
   private final Provider<ListDashboards> listDashboards;
+  private final GetHead getHead;
+  private final SetHead setHead;
+  private final GetParent getParent;
+  private final SetParent setParent;
 
   @AssistedInject
   ProjectApiImpl(
@@ -139,6 +149,10 @@
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
       Provider<ListDashboards> listDashboards,
+      GetHead getHead,
+      SetHead setHead,
+      GetParent getParent,
+      SetParent setParent,
       @Assisted ProjectResource project) {
     this(
         user,
@@ -168,6 +182,10 @@
         dashboardApi,
         checkAccess,
         listDashboards,
+        getHead,
+        setHead,
+        getParent,
+        setParent,
         null);
   }
 
@@ -199,6 +217,10 @@
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
       Provider<ListDashboards> listDashboards,
+      GetHead getHead,
+      SetHead setHead,
+      GetParent getParent,
+      SetParent setParent,
       @Assisted String name) {
     this(
         user,
@@ -228,6 +250,10 @@
         dashboardApi,
         checkAccess,
         listDashboards,
+        getHead,
+        setHead,
+        getParent,
+        setParent,
         name);
   }
 
@@ -259,6 +285,10 @@
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
       Provider<ListDashboards> listDashboards,
+      GetHead getHead,
+      SetHead setHead,
+      GetParent getParent,
+      SetParent setParent,
       String name) {
     this.user = user;
     this.permissionBackend = permissionBackend;
@@ -287,6 +317,10 @@
     this.dashboardApi = dashboardApi;
     this.checkAccess = checkAccess;
     this.listDashboards = listDashboards;
+    this.getHead = getHead;
+    this.setHead = setHead;
+    this.getParent = getParent;
+    this.setParent = setParent;
     this.name = name;
   }
 
@@ -378,7 +412,11 @@
 
   @Override
   public ConfigInfo config(ConfigInput in) throws RestApiException {
-    return putConfig.apply(checkExists(), in);
+    try {
+      return putConfig.apply(checkExists(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list tags", e);
+    }
   }
 
   @Override
@@ -524,6 +562,46 @@
     };
   }
 
+  @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 {
+    HeadInput input = new HeadInput();
+    input.ref = head;
+    try {
+      setHead.apply(checkExists(), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set HEAD", e);
+    }
+  }
+
+  @Override
+  public String parent() throws RestApiException {
+    try {
+      return getParent.apply(checkExists());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get parent", e);
+    }
+  }
+
+  @Override
+  public void parent(String parent) throws RestApiException {
+    try {
+      ParentInput input = new ParentInput();
+      input.parent = parent;
+      setParent.apply(checkExists(), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set parent", e);
+    }
+  }
+
   private ProjectResource checkExists() throws ResourceNotFoundException {
     if (project == null) {
       throw new ResourceNotFoundException(name);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
index 702a7e9..9490075 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
@@ -22,14 +22,18 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
 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.gerrit.server.project.QueryProjects;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.util.List;
 import java.util.SortedMap;
 
 @Singleton
@@ -37,15 +41,18 @@
   private final ProjectsCollection projects;
   private final ProjectApiImpl.Factory api;
   private final Provider<ListProjects> listProvider;
+  private final Provider<QueryProjects> queryProvider;
 
   @Inject
   ProjectsImpl(
       ProjectsCollection projects,
       ProjectApiImpl.Factory api,
-      Provider<ListProjects> listProvider) {
+      Provider<ListProjects> listProvider,
+      Provider<QueryProjects> queryProvider) {
     this.projects = projects;
     this.api = api;
     this.listProvider = listProvider;
+    this.queryProvider = queryProvider;
   }
 
   @Override
@@ -124,4 +131,32 @@
 
     return lp.apply();
   }
+
+  @Override
+  public QueryRequest query() {
+    return new QueryRequest() {
+      @Override
+      public List<ProjectInfo> get() throws RestApiException {
+        return ProjectsImpl.this.query(this);
+      }
+    };
+  }
+
+  @Override
+  public QueryRequest query(String query) {
+    return query().withQuery(query);
+  }
+
+  private List<ProjectInfo> query(QueryRequest r) throws RestApiException {
+    try {
+      QueryProjects myQueryProjects = queryProvider.get();
+      myQueryProjects.setQuery(r.getQuery());
+      myQueryProjects.setLimit(r.getLimit());
+      myQueryProjects.setStart(r.getStart());
+
+      return myQueryProjects.apply(TopLevelResource.INSTANCE);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot query projects", e);
+    }
+  }
 }
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 283d117..9f19c6d 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
@@ -19,6 +19,7 @@
 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.common.Input;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.CreateTag;
@@ -81,7 +82,7 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      deleteTag.apply(resource(), new DeleteTag.Input());
+      deleteTag.apply(resource(), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot delete tag", e);
     }
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/ProjectHandler.java
similarity index 83%
rename from gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectHandler.java
index 1823527..8959d97 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/ProjectHandler.java
@@ -22,7 +22,8 @@
 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.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -36,23 +37,23 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class ProjectControlHandler extends OptionHandler<ProjectControl> {
-  private static final Logger log = LoggerFactory.getLogger(ProjectControlHandler.class);
+public class ProjectHandler extends OptionHandler<ProjectState> {
+  private static final Logger log = LoggerFactory.getLogger(ProjectHandler.class);
 
-  private final ProjectControl.GenericFactory projectControlFactory;
+  private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
 
   @Inject
-  public ProjectControlHandler(
-      ProjectControl.GenericFactory projectControlFactory,
+  public ProjectHandler(
+      ProjectCache projectCache,
       PermissionBackend permissionBackend,
       Provider<CurrentUser> user,
       @Assisted final CmdLineParser parser,
       @Assisted final OptionDef option,
-      @Assisted final Setter<ProjectControl> setter) {
+      @Assisted final Setter<ProjectState> setter) {
     super(parser, option, setter);
-    this.projectControlFactory = projectControlFactory;
+    this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.user = user;
   }
@@ -77,20 +78,21 @@
     String nameWithoutSuffix = ProjectUtil.stripGitSuffix(projectName);
     Project.NameKey nameKey = new Project.NameKey(nameWithoutSuffix);
 
-    ProjectControl control;
+    ProjectState state;
     try {
-      control = projectControlFactory.controlFor(nameKey, user.get());
+      state = projectCache.checkedGet(nameKey);
+      if (state == null) {
+        throw new CmdLineException(owner, String.format("project %s not found", nameWithoutSuffix));
+      }
       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 (PermissionBackendException | IOException e) {
       log.warn("Cannot load project " + nameWithoutSuffix, e);
       throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
     }
 
-    setter.addValue(control);
+    setter.addValue(state);
     return 1;
   }
 
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 18d3482..1ca98b7 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
@@ -17,6 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.common.DiffWebLinkInfo;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AcceptsDelete;
@@ -159,8 +160,7 @@
     }
   }
 
-  public static class DeleteFile implements RestModifyView<ChangeResource, DeleteFile.Input> {
-    public static class Input {}
+  public static class DeleteFile implements RestModifyView<ChangeResource, Input> {
 
     interface Factory {
       DeleteFile create(String path);
@@ -176,7 +176,7 @@
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, DeleteFile.Input in)
+    public Response<?> apply(ChangeResource rsrc, Input in)
         throws IOException, AuthException, ResourceConflictException, OrmException,
             PermissionBackendException {
       return deleteContent.apply(rsrc, path);
@@ -214,7 +214,8 @@
 
     @Override
     public Response<EditInfo> apply(ChangeResource rsrc)
-        throws AuthException, IOException, ResourceNotFoundException, OrmException {
+        throws AuthException, IOException, ResourceNotFoundException, OrmException,
+            PermissionBackendException {
       Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
       if (!edit.isPresent()) {
         return Response.none();
@@ -342,9 +343,7 @@
    * restoring a file to its previous contents.
    */
   @Singleton
-  public static class DeleteContent
-      implements RestModifyView<ChangeEditResource, DeleteContent.Input> {
-    public static class Input {}
+  public static class DeleteContent implements RestModifyView<ChangeEditResource, Input> {
 
     private final ChangeEditModifier editModifier;
     private final GitRepositoryManager repositoryManager;
@@ -356,7 +355,7 @@
     }
 
     @Override
-    public Response<?> apply(ChangeEditResource rsrc, DeleteContent.Input input)
+    public Response<?> apply(ChangeEditResource rsrc, Input input)
         throws AuthException, ResourceConflictException, OrmException, IOException,
             PermissionBackendException {
       return apply(rsrc.getChangeResource(), rsrc.getPath());
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 8dc53bc..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
@@ -83,6 +83,7 @@
 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;
@@ -116,11 +117,12 @@
 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.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.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.project.SubmitRuleOptions;
@@ -228,7 +230,6 @@
   private final ApprovalsUtil approvalsUtil;
   private final RemoveReviewerControl removeReviewerControl;
   private final TrackingFooters trackingFooters;
-  private final ChangeControl.GenericFactory changeControlFactory;
   private boolean lazyLoad = true;
   private AccountLoader accountLoader;
   private FixInput fix;
@@ -261,7 +262,6 @@
       ApprovalsUtil approvalsUtil,
       RemoveReviewerControl removeReviewerControl,
       TrackingFooters trackingFooters,
-      ChangeControl.GenericFactory changeControlFactory,
       @Assisted Iterable<ListChangesOption> options) {
     this.db = db;
     this.userProvider = user;
@@ -287,7 +287,6 @@
     this.indexes = indexes;
     this.approvalsUtil = approvalsUtil;
     this.removeReviewerControl = removeReviewerControl;
-    this.changeControlFactory = changeControlFactory;
     this.options = Sets.immutableEnumSet(options);
     this.trackingFooters = trackingFooters;
   }
@@ -347,6 +346,7 @@
         | OrmException
         | IOException
         | PermissionBackendException
+        | NoSuchProjectException
         | RuntimeException e) {
       if (!has(CHECK)) {
         Throwables.throwIfInstanceOf(e, OrmException.class);
@@ -425,6 +425,7 @@
             | OrmException
             | IOException
             | PermissionBackendException
+            | NoSuchProjectException
             | RuntimeException e) {
           if (has(CHECK)) {
             i = checkOnly(cd);
@@ -491,7 +492,7 @@
 
   private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
       throws PatchListNotAvailableException, GpgException, OrmException, IOException,
-          PermissionBackendException {
+          PermissionBackendException, NoSuchProjectException {
     ChangeInfo out = new ChangeInfo();
     CurrentUser user = userProvider.get();
 
@@ -506,11 +507,7 @@
       }
     }
 
-    PermissionBackend.WithUser withUser = permissionBackend.user(user).database(db);
-    PermissionBackend.ForChange perm =
-        lazyLoad
-            ? withUser.change(cd)
-            : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
+    PermissionBackend.ForChange perm = permissionBackendForChange(user, cd);
     Change in = cd.change();
     out.project = in.getProject().get();
     out.branch = in.getDest().getShortName();
@@ -596,19 +593,15 @@
       src = null;
     }
 
-    ChangeControl ctl = null;
-    if (needMessages || needRevisions) {
-      ctl = changeControlFactory.controlFor(db.get(), cd.change(), userProvider.get());
-    }
     if (needMessages) {
-      out.messages = messages(ctl, cd);
+      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, limitToPsId, 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) {
@@ -1103,8 +1096,7 @@
     return result;
   }
 
-  private Collection<ChangeMessageInfo> messages(ChangeControl ctl, ChangeData cd)
-      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();
@@ -1113,26 +1105,24 @@
     List<ChangeMessageInfo> result = Lists.newArrayListWithCapacity(messages.size());
     for (ChangeMessage message : messages) {
       PatchSet.Id patchNum = message.getPatchSetId();
-      if (patchNum == null || ctl.isVisible(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;
-        Account.Id realAuthor = message.getRealAuthor();
-        if (realAuthor != null) {
-          cmi.realAuthor = accountLoader.get(realAuthor);
-        }
-        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(ChangeData cd, ChangeInfo out)
-      throws PermissionBackendException, NoSuchChangeException, OrmException {
+      throws PermissionBackendException, NoSuchProjectException, OrmException, IOException {
     // Although this is called removableReviewers, this method also determines
     // which CCs are removable.
     //
@@ -1228,13 +1218,14 @@
   }
 
   private Map<String, RevisionInfo> revisions(
-      ChangeControl ctl,
       ChangeData cd,
       Map<PatchSet.Id, PatchSet> map,
       Optional<PatchSet.Id> limitToPsId,
       ChangeInfo changeInfo)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
+          PermissionBackendException {
     Map<String, RevisionInfo> res = new LinkedHashMap<>();
+    Boolean isWorldReadable = null;
     try (Repository repo = openRepoIfNecessary(cd.project());
         RevWalk rw = newRevWalk(repo)) {
       for (PatchSet in : map.values()) {
@@ -1247,8 +1238,13 @@
         } else {
           want = id.equals(cd.change().currentPatchSetId());
         }
-        if (want && ctl.isVisible(db.get())) {
-          res.put(in.getRevision().get(), toRevisionInfo(cd, in, repo, rw, false, changeInfo));
+        if (want) {
+          if (isWorldReadable == null) {
+            isWorldReadable = isWorldReadable(cd);
+          }
+          res.put(
+              in.getRevision().get(),
+              toRevisionInfo(cd, in, repo, rw, false, changeInfo, isWorldReadable));
         }
       }
       return res;
@@ -1283,11 +1279,12 @@
   }
 
   public RevisionInfo getRevisionInfo(ChangeData cd, PatchSet in)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
+          PermissionBackendException {
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
     try (Repository repo = openRepoIfNecessary(cd.project());
         RevWalk rw = newRevWalk(repo)) {
-      RevisionInfo rev = toRevisionInfo(cd, in, repo, rw, true, null);
+      RevisionInfo rev = toRevisionInfo(cd, in, repo, rw, true, null, isWorldReadable(cd));
       accountLoader.fill();
       return rev;
     }
@@ -1299,7 +1296,8 @@
       @Nullable Repository repo,
       @Nullable RevWalk rw,
       boolean fillCommit,
-      @Nullable ChangeInfo changeInfo)
+      @Nullable ChangeInfo changeInfo,
+      boolean isWorldReadable)
       throws PatchListNotAvailableException, GpgException, OrmException, IOException {
     Change c = cd.change();
     RevisionInfo out = new RevisionInfo();
@@ -1308,7 +1306,7 @@
     out.ref = in.getRefName();
     out.created = in.getCreatedOn();
     out.uploader = accountLoader.get(in.getUploader());
-    out.fetch = makeFetchMap(cd, in);
+    out.fetch = makeFetchMap(cd, in, isWorldReadable);
     out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
     out.description = in.getDescription();
 
@@ -1398,10 +1396,8 @@
     return info;
   }
 
-  private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in) throws OrmException {
+  private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in, boolean isWorldReadable) {
     Map<String, FetchInfo> r = new LinkedHashMap<>();
-
-    ChangeControl ctl = changeControlFactory.controlFor(db.get(), cd.change(), anonymous);
     for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
       String schemeName = e.getExportName();
       DownloadScheme scheme = e.getProvider().get();
@@ -1409,8 +1405,7 @@
           || (scheme.isAuthRequired() && !userProvider.get().isIdentifiedUser())) {
         continue;
       }
-
-      if (!scheme.isAuthSupported() && !ctl.isVisible(db.get())) {
+      if (!scheme.isAuthSupported() && !isWorldReadable) {
         continue;
       }
 
@@ -1464,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/ChangesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
index 805512e..a56612c5 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
@@ -16,6 +16,7 @@
 
 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.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -126,6 +127,11 @@
   }
 
   private boolean canRead(ChangeNotes notes) throws PermissionBackendException {
-    return permissionBackend.user(user).change(notes).database(db).test(ChangePermission.READ);
+    try {
+      permissionBackend.user(user).change(notes).database(db).check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 }
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 157928b..c07659f 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,6 +17,7 @@
 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;
@@ -25,8 +26,8 @@
 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.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;
@@ -37,18 +38,12 @@
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final ChangeJson.Factory jsonFactory;
-  private final ProjectControl.GenericFactory projectControlFactory;
 
   @Inject
-  Check(
-      PermissionBackend permissionBackend,
-      Provider<CurrentUser> user,
-      ChangeJson.Factory json,
-      ProjectControl.GenericFactory projectControlFactory) {
+  Check(PermissionBackend permissionBackend, Provider<CurrentUser> user, ChangeJson.Factory json) {
     this.permissionBackend = permissionBackend;
     this.user = user;
     this.jsonFactory = json;
-    this.projectControlFactory = projectControlFactory;
   }
 
   @Override
@@ -60,9 +55,13 @@
   public Response<ChangeInfo> apply(ChangeResource rsrc, FixInput input)
       throws RestApiException, OrmException, PermissionBackendException, NoSuchProjectException,
           IOException {
-    if (!rsrc.isUserOwner()
-        && !projectControlFactory.controlFor(rsrc.getProject(), rsrc.getUser()).isOwner()) {
-      permissionBackend.user(user).check(GlobalPermission.MAINTAIN_SERVER);
+    PermissionBackend.WithUser perm = permissionBackend.user(user);
+    if (!rsrc.isUserOwner()) {
+      try {
+        perm.project(rsrc.getProject()).check(ProjectPermission.READ_CONFIG);
+      } catch (AuthException e) {
+        perm.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/CherryPickChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
index 4f03f37..8b84f2b 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
@@ -49,7 +49,7 @@
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchProjectException;
-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.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -92,7 +92,7 @@
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final MergeUtil.Factory mergeUtilFactory;
   private final ChangeNotes.Factory changeNotesFactory;
-  private final ProjectControl.GenericFactory projectControlFactory;
+  private final ProjectCache projectCache;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil changeMessagesUtil;
   private final NotifyUtil notifyUtil;
@@ -109,7 +109,7 @@
       PatchSetInserter.Factory patchSetInserterFactory,
       MergeUtil.Factory mergeUtilFactory,
       ChangeNotes.Factory changeNotesFactory,
-      ProjectControl.GenericFactory projectControlFactory,
+      ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil changeMessagesUtil,
       NotifyUtil notifyUtil) {
@@ -123,7 +123,7 @@
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.mergeUtilFactory = mergeUtilFactory;
     this.changeNotesFactory = changeNotesFactory;
-    this.projectControlFactory = projectControlFactory;
+    this.projectCache = projectCache;
     this.approvalsUtil = approvalsUtil;
     this.changeMessagesUtil = changeMessagesUtil;
     this.notifyUtil = notifyUtil;
@@ -197,10 +197,11 @@
       String commitMessage = ChangeIdUtil.insertId(input.message, computedChangeId).trim() + '\n';
 
       CodeReviewCommit cherryPickCommit;
-      ProjectControl projectControl =
-          projectControlFactory.controlFor(dest.getParentKey(), identifiedUser);
+      ProjectState projectState = projectCache.checkedGet(dest.getParentKey());
+      if (projectState == null) {
+        throw new NoSuchProjectException(dest.getParentKey());
+      }
       try {
-        ProjectState projectState = projectControl.getProjectState();
         cherryPickCommit =
             mergeUtilFactory
                 .create(projectState)
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 d3feb31..8d8d72e 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,6 +16,7 @@
 
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -25,7 +26,6 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountLoader;
-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;
@@ -45,7 +45,6 @@
 @Singleton
 public class DeleteAssignee
     extends RetryingRestModifyView<ChangeResource, Input, Response<AccountInfo>> {
-  public static class Input {}
 
   private final ChangeMessagesUtil cmUtil;
   private final Provider<ReviewDb> db;
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 af26e8a..69f6178 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
@@ -17,13 +17,13 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.common.Input;
 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.change.DeleteChange.Input;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -39,7 +39,6 @@
 @Singleton
 public class DeleteChange extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
     implements UiAction<ChangeResource> {
-  public static class Input {}
 
   private final Provider<ReviewDb> db;
   private final Provider<DeleteChangeOp> opProvider;
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 e2e3920..480aca1 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
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 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.RestModifyView;
-import com.google.gerrit.server.change.DeleteChangeEdit.Input;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gwtorm.server.OrmException;
@@ -29,7 +29,6 @@
 
 @Singleton
 public class DeleteChangeEdit implements RestModifyView<ChangeResource, Input> {
-  public static class Input {}
 
   private final ChangeEditUtil editUtil;
 
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 68db189..6d82139 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
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -26,7 +27,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 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.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -44,7 +44,6 @@
 @Singleton
 public class DeleteDraftComment
     extends RetryingRestModifyView<DraftCommentResource, Input, Response<CommentInfo>> {
-  static class Input {}
 
   private final Provider<ReviewDb> db;
   private final CommentsUtil commentsUtil;
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
index c743769..91f7720 100644
--- 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
@@ -39,6 +39,7 @@
 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;
@@ -119,7 +120,7 @@
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws AuthException, ResourceNotFoundException, OrmException, PermissionBackendException,
-          IOException {
+          IOException, NoSuchProjectException {
     Account.Id reviewerId = reviewer.getId();
     if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all().contains(reviewerId)) {
       throw new ResourceNotFoundException();
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 10164ce..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
@@ -41,6 +41,7 @@
 import com.google.gerrit.server.mail.send.DeleteVoteSender;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 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;
@@ -162,7 +163,7 @@
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws OrmException, AuthException, ResourceNotFoundException, IOException,
-            PermissionBackendException {
+            PermissionBackendException, NoSuchProjectException {
       change = ctx.getChange();
       PatchSet.Id psId = change.currentPatchSetId();
       ps = psUtil.current(db.get(), ctx.getNotes());
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 21da0b8..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
@@ -42,6 +42,8 @@
 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;
@@ -145,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));
@@ -240,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);
         }
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 c91748c..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
@@ -47,6 +47,7 @@
 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;
@@ -122,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;
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
index 6002f75..27c5d49 100644
--- 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
@@ -26,7 +26,6 @@
 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.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -53,7 +52,6 @@
   private final ChangeNotes.Factory notesFactory;
   private final Provider<ReviewDb> dbProvider;
   private final PatchSetUtil psUtil;
-  private final ChangeControl.GenericFactory changeControlFactory;
 
   @Option(
     name = "--claimed-original",
@@ -70,15 +68,13 @@
       ProjectCache projectCache,
       ChangeNotes.Factory notesFactory,
       Provider<ReviewDb> dbProvider,
-      PatchSetUtil psUtil,
-      ChangeControl.GenericFactory changeControlFactory) {
+      PatchSetUtil psUtil) {
     this.mergeUtilFactory = mergeUtilFactory;
     this.repoManager = repoManager;
     this.projectCache = projectCache;
     this.notesFactory = notesFactory;
     this.dbProvider = dbProvider;
     this.psUtil = psUtil;
-    this.changeControlFactory = changeControlFactory;
   }
 
   @Override
@@ -88,10 +84,6 @@
     PatchSet currentPatchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
     if (currentPatchSet == null) {
       throw new ResourceConflictException("current revision is missing");
-    } else if (!changeControlFactory
-        .controlFor(rsrc.getNotes(), rsrc.getUser())
-        .isVisible(dbProvider.get())) {
-      throw new AuthException("current revision not accessible");
     }
     return getPureRevert(rsrc.getNotes());
   }
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 a6583b1..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
@@ -27,6 +27,7 @@
 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;
@@ -64,14 +65,15 @@
 
   @Override
   public RelatedInfo apply(RevisionResource rsrc)
-      throws RepositoryNotFoundException, IOException, OrmException, NoSuchProjectException {
+      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, NoSuchProjectException {
+      throws OrmException, IOException, PermissionBackendException {
     Set<String> groups = getAllGroups(rsrc.getNotes());
     if (groups.isEmpty()) {
       return Collections.emptyList();
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
index 46dabdf..c2c2d1f 100644
--- 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
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -30,12 +31,9 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class Ignore
-    implements RestModifyView<ChangeResource, Ignore.Input>, UiAction<ChangeResource> {
+public class Ignore implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Ignore.class);
 
-  public static class Input {}
-
   private final StarredChangesUtil stars;
 
   @Inject
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 7c4d158..85e13cc 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
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 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.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -34,7 +34,6 @@
 
 @Singleton
 public class Index extends RetryingRestModifyView<ChangeResource, Input, Response<?>> {
-  public static class Input {}
 
   private final Provider<ReviewDb> db;
   private final PermissionBackend permissionBackend;
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
index 265b2b0..9e77805 100644
--- 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
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -31,11 +32,9 @@
 
 @Singleton
 public class MarkAsReviewed
-    implements RestModifyView<ChangeResource, MarkAsReviewed.Input>, UiAction<ChangeResource> {
+    implements RestModifyView<ChangeResource, 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;
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
index 6de84ee..436548b 100644
--- 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
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -30,11 +31,9 @@
 
 @Singleton
 public class MarkAsUnreviewed
-    implements RestModifyView<ChangeResource, MarkAsUnreviewed.Input>, UiAction<ChangeResource> {
+    implements RestModifyView<ChangeResource, 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;
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 2f3855c..27d4eb1 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
@@ -21,6 +21,7 @@
 
 import com.google.common.base.Strings;
 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;
@@ -31,18 +32,24 @@
 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.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;
@@ -55,7 +62,8 @@
 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;
@@ -71,6 +79,9 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
+  private final ApprovalsUtil approvalsUtil;
+  private final ProjectCache projectCache;
+  private final Provider<CurrentUser> userProvider;
 
   @Inject
   Move(
@@ -81,7 +92,10 @@
       Provider<InternalChangeQuery> queryProvider,
       ChangeMessagesUtil cmUtil,
       RetryHelper retryHelper,
-      PatchSetUtil psUtil) {
+      PatchSetUtil psUtil,
+      ApprovalsUtil approvalsUtil,
+      ProjectCache projectCache,
+      Provider<CurrentUser> userProvider) {
     super(retryHelper);
     this.permissionBackend = permissionBackend;
     this.dbProvider = dbProvider;
@@ -90,6 +104,9 @@
     this.queryProvider = queryProvider;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
+    this.approvalsUtil = approvalsUtil;
+    this.projectCache = projectCache;
+    this.userProvider = userProvider;
   }
 
   @Override
@@ -138,7 +155,7 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws OrmException, ResourceConflictException, RepositoryNotFoundException, IOException {
+        throws OrmException, ResourceConflictException, IOException {
       change = ctx.getChange();
       if (change.getStatus() != Status.NEW) {
         throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
@@ -188,10 +205,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());
@@ -207,6 +227,46 @@
 
       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.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);
+    }
   }
 
   @Override
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 58634a5..0022656 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
@@ -233,7 +233,7 @@
       input.drafts = DraftHandling.DELETE;
     }
     if (input.labels != null) {
-      checkLabels(revision, labelTypes, input.strictLabels, input.labels);
+      checkLabels(revision, labelTypes, input.labels);
     }
     if (input.comments != null) {
       cleanUpComments(input.comments);
@@ -443,12 +443,9 @@
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
       LabelType type = labelTypes.byLabel(ent.getKey());
-      if (type == null && in.strictLabels) {
+      if (type == null) {
         throw new BadRequestException(
             String.format("label \"%s\" is not a configured label", ent.getKey()));
-      } else if (type == null) {
-        itr.remove();
-        continue;
       }
 
       if (!caller.isInternalUser()) {
@@ -479,8 +476,7 @@
         changeResourceFactory.create(rev.getNotes(), reviewer), rev.getPatchSet());
   }
 
-  private void checkLabels(
-      RevisionResource rsrc, LabelTypes labelTypes, boolean strict, Map<String, Short> labels)
+  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();
@@ -488,12 +484,8 @@
       Map.Entry<String, Short> ent = itr.next();
       LabelType lt = labelTypes.byLabel(ent.getKey());
       if (lt == null) {
-        if (strict) {
-          throw new BadRequestException(
-              String.format("label \"%s\" is not a configured label", ent.getKey()));
-        }
-        itr.remove();
-        continue;
+        throw new BadRequestException(
+            String.format("label \"%s\" is not a configured label", ent.getKey()));
       }
 
       if (ent.getValue() == null || ent.getValue() == 0) {
@@ -503,23 +495,16 @@
       }
 
       if (lt.getValue(ent.getValue()) == null) {
-        if (strict) {
-          throw new BadRequestException(
-              String.format("label \"%s\": %d is not a valid value", ent.getKey(), ent.getValue()));
-        }
-        itr.remove();
-        continue;
+        throw new BadRequestException(
+            String.format("label \"%s\": %d is not a valid value", ent.getKey(), ent.getValue()));
       }
 
       short val = ent.getValue();
       try {
         perm.check(new LabelPermission.WithValue(lt, val));
       } catch (AuthException e) {
-        if (strict) {
-          throw new AuthException(
-              String.format("Applying label \"%s\": %d is restricted", lt.getName(), val));
-        }
-        ent.setValue(perm.squashThenCheck(lt, val));
+        throw new AuthException(
+            String.format("Applying label \"%s\": %d is restricted", lt.getName(), val));
       }
     }
   }
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 f642aa4..3664293 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
@@ -91,7 +91,7 @@
   private final PermissionBackend permissionBackend;
 
   private final GroupsCollection groupsCollection;
-  private final GroupMembers.Factory groupMembersFactory;
+  private final GroupMembers groupMembers;
   private final AccountLoader.Factory accountLoaderFactory;
   private final Provider<ReviewDb> dbProvider;
   private final ChangeData.Factory changeDataFactory;
@@ -111,7 +111,7 @@
       ReviewerResource.Factory reviewerFactory,
       PermissionBackend permissionBackend,
       GroupsCollection groupsCollection,
-      GroupMembers.Factory groupMembersFactory,
+      GroupMembers groupMembers,
       AccountLoader.Factory accountLoaderFactory,
       Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
@@ -130,7 +130,7 @@
     this.reviewerFactory = reviewerFactory;
     this.permissionBackend = permissionBackend;
     this.groupsCollection = groupsCollection;
-    this.groupMembersFactory = groupMembersFactory;
+    this.groupMembers = groupMembers;
     this.accountLoaderFactory = accountLoaderFactory;
     this.dbProvider = db;
     this.changeDataFactory = changeDataFactory;
@@ -287,10 +287,7 @@
     Set<Account.Id> reviewers = new HashSet<>();
     Set<Account> members;
     try {
-      members =
-          groupMembersFactory
-              .create(rsrc.getUser())
-              .listAccounts(group.getGroupUUID(), rsrc.getProject());
+      members = groupMembers.listAccounts(group.getGroupUUID(), rsrc.getProject());
     } catch (NoSuchGroupException e) {
       return fail(
           reviewer,
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 4c9cf23..0d932ec 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,7 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -42,16 +42,12 @@
 
 @Singleton
 public class PutDescription
-    extends RetryingRestModifyView<RevisionResource, PutDescription.Input, Response<String>>
+    extends RetryingRestModifyView<RevisionResource, DescriptionInput, Response<String>>
     implements UiAction<RevisionResource> {
   private final Provider<ReviewDb> dbProvider;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
 
-  public static class Input {
-    @DefaultInput public String description;
-  }
-
   @Inject
   PutDescription(
       Provider<ReviewDb> dbProvider,
@@ -66,11 +62,11 @@
 
   @Override
   protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, RevisionResource rsrc, Input input)
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, DescriptionInput input)
       throws UpdateException, RestApiException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.EDIT_DESCRIPTION);
 
-    Op op = new Op(input != null ? input : new Input(), rsrc.getPatchSet().getId());
+    Op op = new Op(input != null ? input : new DescriptionInput(), rsrc.getPatchSet().getId());
     try (BatchUpdate u =
         updateFactory.create(
             dbProvider.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
@@ -83,13 +79,13 @@
   }
 
   private class Op implements BatchUpdateOp {
-    private final Input input;
+    private final DescriptionInput input;
     private final PatchSet.Id psId;
 
     private String oldDescription;
     private String newDescription;
 
-    Op(Input input, PatchSet.Id psId) {
+    Op(DescriptionInput input, PatchSet.Id psId) {
       this.input = input;
       this.psId = psId;
     }
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
index 5edef0a..aa10af8 100644
--- 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
@@ -35,7 +35,6 @@
 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.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
@@ -73,7 +72,6 @@
   private final PatchSetUtil psUtil;
   private final NotifyUtil notifyUtil;
   private final ProjectCache projectCache;
-  private final ChangeControl.GenericFactory changeControlFactory;
 
   @Inject
   PutMessage(
@@ -86,8 +84,7 @@
       @GerritPersonIdent PersonIdent gerritIdent,
       PatchSetUtil psUtil,
       NotifyUtil notifyUtil,
-      ProjectCache projectCache,
-      ChangeControl.GenericFactory changeControlFactory) {
+      ProjectCache projectCache) {
     super(retryHelper);
     this.repositoryManager = repositoryManager;
     this.currentUserProvider = currentUserProvider;
@@ -98,7 +95,6 @@
     this.psUtil = psUtil;
     this.notifyUtil = notifyUtil;
     this.projectCache = projectCache;
-    this.changeControlFactory = changeControlFactory;
   }
 
   @Override
@@ -109,10 +105,6 @@
     PatchSet ps = psUtil.current(db.get(), resource.getNotes());
     if (ps == null) {
       throw new ResourceConflictException("current revision is missing");
-    } else if (!changeControlFactory
-        .controlFor(resource.getNotes(), resource.getUser())
-        .isVisible(db.get())) {
-      throw new AuthException("current revision not accessible");
     }
 
     if (input == null) {
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 8b5608b..a461bf0 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,8 +16,8 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.TopicInput;
 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.webui.UiAction;
@@ -26,7 +26,6 @@
 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.permissions.ChangePermission;
@@ -44,16 +43,12 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class PutTopic extends RetryingRestModifyView<ChangeResource, Input, Response<String>>
+public class PutTopic extends RetryingRestModifyView<ChangeResource, TopicInput, Response<String>>
     implements UiAction<ChangeResource> {
   private final Provider<ReviewDb> dbProvider;
   private final ChangeMessagesUtil cmUtil;
   private final TopicEdited topicEdited;
 
-  public static class Input {
-    @DefaultInput public String topic;
-  }
-
   @Inject
   PutTopic(
       Provider<ReviewDb> dbProvider,
@@ -68,7 +63,7 @@
 
   @Override
   protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource req, Input input)
+      BatchUpdate.Factory updateFactory, ChangeResource req, TopicInput input)
       throws UpdateException, RestApiException, PermissionBackendException {
     req.permissions().check(ChangePermission.EDIT_TOPIC_NAME);
 
@@ -79,7 +74,7 @@
           String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
     }
 
-    Op op = new Op(input != null ? input : new Input());
+    Op op = new Op(input != null ? input : new TopicInput());
     try (BatchUpdate u =
         updateFactory.create(
             dbProvider.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
@@ -90,13 +85,13 @@
   }
 
   private class Op implements BatchUpdateOp {
-    private final Input input;
+    private final TopicInput input;
 
     private Change change;
     private String oldTopicName;
     private String newTopicName;
 
-    Op(Input input) {
+    Op(TopicInput input) {
       this.input = input;
     }
 
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 7d92973..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
@@ -35,13 +35,12 @@
 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.PatchSetUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.git.GitRepositoryManager;
 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.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
@@ -74,8 +73,7 @@
   private final RebaseUtil rebaseUtil;
   private final ChangeJson.Factory json;
   private final Provider<ReviewDb> dbProvider;
-  private final Provider<CurrentUser> userProvider;
-  private final ChangeControl.GenericFactory changeControlFactory;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   public Rebase(
@@ -85,16 +83,14 @@
       RebaseUtil rebaseUtil,
       ChangeJson.Factory json,
       Provider<ReviewDb> dbProvider,
-      Provider<CurrentUser> userProvider,
-      ChangeControl.GenericFactory changeControlFactory) {
+      PermissionBackend permissionBackend) {
     super(retryHelper);
     this.repoManager = repoManager;
     this.rebaseFactory = rebaseFactory;
     this.rebaseUtil = rebaseUtil;
     this.json = json;
     this.dbProvider = dbProvider;
-    this.userProvider = userProvider;
-    this.changeControlFactory = changeControlFactory;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
@@ -132,7 +128,8 @@
 
   private ObjectId findBaseRev(
       Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
-      throws RestApiException, OrmException, IOException, NoSuchChangeException {
+      throws RestApiException, OrmException, IOException, NoSuchChangeException, AuthException,
+          PermissionBackendException {
     Branch.NameKey destRefKey = rsrc.getChange().getDest();
     if (input == null || input.base == null) {
       return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
@@ -150,20 +147,21 @@
       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();
-    ChangeControl baseCtl = changeControlFactory.controlFor(base.notes(), userProvider.get());
-    if (!baseCtl.isVisible(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");
     }
 
+    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(
@@ -228,18 +226,12 @@
       extends RetryingRestModifyView<ChangeResource, RebaseInput, ChangeInfo> {
     private final PatchSetUtil psUtil;
     private final Rebase rebase;
-    private final ChangeControl.GenericFactory changeControlFactory;
 
     @Inject
-    CurrentRevision(
-        RetryHelper retryHelper,
-        PatchSetUtil psUtil,
-        Rebase rebase,
-        ChangeControl.GenericFactory changeControlFactory) {
+    CurrentRevision(RetryHelper retryHelper, PatchSetUtil psUtil, Rebase rebase) {
       super(retryHelper);
       this.psUtil = psUtil;
       this.rebase = rebase;
-      this.changeControlFactory = changeControlFactory;
     }
 
     @Override
@@ -250,10 +242,6 @@
       PatchSet ps = psUtil.current(rebase.dbProvider.get(), rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
-      } else if (!changeControlFactory
-          .controlFor(rsrc.getNotes(), rsrc.getUser())
-          .isVisible(rebase.dbProvider.get())) {
-        throw new AuthException("current revision not accessible");
       }
       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 38a695a..2909827 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
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -68,8 +69,7 @@
   }
 
   @Singleton
-  public static class Rebase implements RestModifyView<ChangeResource, Rebase.Input> {
-    public static class Input {}
+  public static class Rebase implements RestModifyView<ChangeResource, Input> {
 
     private final GitRepositoryManager repositoryManager;
     private final ChangeEditModifier editModifier;
@@ -81,7 +81,7 @@
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, Rebase.Input in)
+    public Response<?> apply(ChangeResource rsrc, Input in)
         throws AuthException, ResourceConflictException, IOException, OrmException,
             PermissionBackendException {
       Project.NameKey project = rsrc.getProject();
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..a3ed670 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
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.joining;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -23,7 +24,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.change.Rebuild.Input;
 import com.google.gerrit.server.notedb.ChangeBundle;
 import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -40,7 +40,6 @@
 
 @Singleton
 public class Rebuild implements RestModifyView<ChangeResource, Input> {
-  public static class Input {}
 
   private final Provider<ReviewDb> db;
   private final NotesMigration migration;
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 86d6e81..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,16 +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.NoSuchProjectException;
-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;
@@ -55,23 +59,27 @@
 @Singleton
 class RelatedChangesSorter {
   private final GitRepositoryManager repoManager;
-  private final ProjectControl.GenericFactory projectControlFactory;
+  private final PermissionBackend permissionBackend;
+  private final Provider<ReviewDb> dbProvider;
 
   @Inject
   RelatedChangesSorter(
-      GitRepositoryManager repoManager, ProjectControl.GenericFactory projectControlFactory) {
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      Provider<ReviewDb> dbProvider) {
     this.repoManager = repoManager;
-    this.projectControlFactory = projectControlFactory;
+    this.permissionBackend = permissionBackend;
+    this.dbProvider = dbProvider;
   }
 
   public List<PatchSetData> sort(List<ChangeData> in, PatchSet startPs, CurrentUser user)
-      throws OrmException, IOException, NoSuchProjectException {
+      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 = projectControlFactory.controlFor(start.data().project(), user);
+    PermissionBackend.WithUser perm = permissionBackend.user(user).database(dbProvider);
 
     // Map of patch set -> immediate parent.
     ListMultimap<PatchSetData, PatchSetData> parents =
@@ -98,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);
@@ -133,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);
@@ -150,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;
   }
 
@@ -179,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();
     }
@@ -194,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);
@@ -225,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/Reviewed.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
index 0d25d35..c412adf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -22,7 +23,6 @@
 import com.google.inject.Singleton;
 
 public class Reviewed {
-  public static class Input {}
 
   @Singleton
   public static class PutReviewed implements RestModifyView<FileResource, Input> {
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 ef039dd..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,7 +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.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.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -46,7 +48,7 @@
   private final Provider<ReviewDb> dbProvider;
   private final ChangeEditUtil editUtil;
   private final PatchSetUtil psUtil;
-  private final ChangeControl.GenericFactory changeControlFactory;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   Revisions(
@@ -54,12 +56,12 @@
       Provider<ReviewDb> dbProvider,
       ChangeEditUtil editUtil,
       PatchSetUtil psUtil,
-      ChangeControl.GenericFactory changeControlFactory) {
+      PermissionBackend permissionBackend) {
     this.views = views;
     this.dbProvider = dbProvider;
     this.editUtil = editUtil;
     this.psUtil = psUtil;
-    this.changeControlFactory = changeControlFactory;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
@@ -74,7 +76,8 @@
 
   @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)) {
@@ -100,10 +103,17 @@
     }
   }
 
-  private boolean visible(ChangeResource change) throws OrmException {
-    return changeControlFactory
-        .controlFor(change.getNotes(), change.getUser())
-        .isVisible(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)
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 cab61b3..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
@@ -55,7 +55,6 @@
 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.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -507,20 +506,17 @@
     private final Submit submit;
     private final ChangeJson.Factory json;
     private final PatchSetUtil psUtil;
-    private final ChangeControl.GenericFactory changeControlFactory;
 
     @Inject
     CurrentRevision(
         Provider<ReviewDb> dbProvider,
         Submit submit,
         ChangeJson.Factory json,
-        PatchSetUtil psUtil,
-        ChangeControl.GenericFactory changeControlFactory) {
+        PatchSetUtil psUtil) {
       this.dbProvider = dbProvider;
       this.submit = submit;
       this.json = json;
       this.psUtil = psUtil;
-      this.changeControlFactory = changeControlFactory;
     }
 
     @Override
@@ -530,10 +526,6 @@
       PatchSet ps = psUtil.current(dbProvider.get(), rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
-      } else if (!changeControlFactory
-          .controlFor(rsrc.getNotes(), rsrc.getUser())
-          .isVisible(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/Unignore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java
index 2bad16c..39a82d7 100644
--- 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
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -26,12 +27,9 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class Unignore
-    implements RestModifyView<ChangeResource, Unignore.Input>, UiAction<ChangeResource> {
+public class Unignore implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Unignore.class);
 
-  public static class Input {}
-
   private final StarredChangesUtil stars;
 
   @Inject
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/DeleteTask.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/DeleteTask.java
index 29ca20f..d20589a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/DeleteTask.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/DeleteTask.java
@@ -19,16 +19,15 @@
 import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.config.DeleteTask.Input;
 import com.google.gerrit.server.git.WorkQueue.Task;
 import com.google.inject.Singleton;
 
 @Singleton
 @RequiresAnyCapability({KILL_TASK, MAINTAIN_SERVER})
 public class DeleteTask implements RestModifyView<TaskResource, Input> {
-  public static class Input {}
 
   @Override
   public Response<?> apply(TaskResource rsrc, Input input) {
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 366dae1..5d2ec36 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
@@ -18,11 +18,11 @@
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
 
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.FlushCache.Input;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -33,7 +33,6 @@
 @RequiresAnyCapability({FLUSH_CACHES, MAINTAIN_SERVER})
 @Singleton
 public class FlushCache implements RestModifyView<CacheResource, Input> {
-  public static class Input {}
 
   public static final String WEB_SESSIONS = "web_sessions";
 
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 0e4e8b4..f7603f0 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
@@ -50,6 +50,7 @@
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.events.PluginEventListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
+import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
 import com.google.gerrit.extensions.events.ReviewerDeletedListener;
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
@@ -91,7 +92,6 @@
 import com.google.gerrit.server.account.GroupCacheImpl;
 import com.google.gerrit.server.account.GroupControl;
 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;
@@ -114,6 +114,7 @@
 import com.google.gerrit.server.git.EmailMerge;
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.GitModules;
+import com.google.gerrit.server.git.MergeSuperSetComputation;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.NotesBranchUtil;
@@ -151,7 +152,6 @@
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.mail.send.SetAssigneeSender;
-import com.google.gerrit.server.mail.send.VelocityRuntimeProvider;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.notedb.NoteDbModule;
@@ -163,6 +163,7 @@
 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.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectNode;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SectionSortCache;
@@ -188,7 +189,6 @@
 import com.google.inject.internal.UniqueAnnotations;
 import com.google.template.soy.tofu.SoyTofu;
 import java.util.List;
-import org.apache.velocity.runtime.RuntimeInstance;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.transport.PostReceiveHook;
 import org.eclipse.jgit.transport.PostUploadHook;
@@ -250,7 +250,6 @@
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeJson.AssistedFactory.class);
     factory(CreateChangeSender.Factory.class);
-    factory(GroupMembers.Factory.class);
     factory(EmailMerge.Factory.class);
     factory(MergedSender.Factory.class);
     factory(MergeUtil.Factory.class);
@@ -284,7 +283,6 @@
 
     bind(ApprovalsUtil.class);
 
-    bind(RuntimeInstance.class).toProvider(VelocityRuntimeProvider.class);
     bind(SoyTofu.class).annotatedWith(MailTemplates.class).toProvider(MailSoyTofuProvider.class);
     bind(FromAddressGenerator.class).toProvider(FromAddressGeneratorProvider.class).in(SINGLETON);
     bind(Boolean.class)
@@ -332,6 +330,7 @@
     DynamicSet.setOf(binder(), AccountIndexedListener.class);
     DynamicSet.setOf(binder(), ChangeIndexedListener.class);
     DynamicSet.setOf(binder(), GroupIndexedListener.class);
+    DynamicSet.setOf(binder(), ProjectIndexedListener.class);
     DynamicSet.setOf(binder(), NewProjectCreatedListener.class);
     DynamicSet.setOf(binder(), ProjectDeletedListener.class);
     DynamicSet.setOf(binder(), GarbageCollectorListener.class);
@@ -377,6 +376,8 @@
     DynamicItem.itemOf(binder(), AccountPatchReviewStore.class);
     DynamicSet.setOf(binder(), AssigneeValidationListener.class);
     DynamicSet.setOf(binder(), ActionVisitor.class);
+    DynamicItem.itemOf(binder(), MergeSuperSetComputation.class);
+    DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritRequestModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritRequestModule.java
index 5d88ec0..3c9168a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritRequestModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritRequestModule.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RequestCleanup;
 import com.google.gerrit.server.project.PerRequestProjectControlCache;
-import com.google.gerrit.server.project.ProjectControl;
 import com.google.inject.servlet.RequestScoped;
 
 /** Bindings for {@link RequestScoped} entities. */
@@ -32,6 +31,5 @@
     bind(IdentifiedUser.RequestFactory.class).in(SINGLETON);
 
     bind(PerRequestProjectControlCache.class).in(RequestScoped.class);
-    bind(ProjectControl.Factory.class).in(SINGLETON);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ScheduleConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ScheduleConfig.java
index 4a87474..c5d60a3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ScheduleConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ScheduleConfig.java
@@ -16,16 +16,16 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import java.text.MessageFormat;
+import java.time.DayOfWeek;
+import java.time.Duration;
+import java.time.LocalTime;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoUnit;
 import java.util.Locale;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
-import org.joda.time.DateTime;
-import org.joda.time.LocalDateTime;
-import org.joda.time.LocalTime;
-import org.joda.time.MutableDateTime;
-import org.joda.time.format.DateTimeFormat;
-import org.joda.time.format.DateTimeFormatter;
-import org.joda.time.format.ISODateTimeFormat;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -49,16 +49,16 @@
   }
 
   public ScheduleConfig(Config rc, String section, String subsection) {
-    this(rc, section, subsection, DateTime.now());
+    this(rc, section, subsection, ZonedDateTime.now());
   }
 
   public ScheduleConfig(
       Config rc, String section, String subsection, String keyInterval, String keyStartTime) {
-    this(rc, section, subsection, keyInterval, keyStartTime, DateTime.now());
+    this(rc, section, subsection, keyInterval, keyStartTime, ZonedDateTime.now());
   }
 
   @VisibleForTesting
-  ScheduleConfig(Config rc, String section, String subsection, DateTime now) {
+  ScheduleConfig(Config rc, String section, String subsection, ZonedDateTime now) {
     this(rc, section, subsection, KEY_INTERVAL, KEY_STARTTIME, now);
   }
 
@@ -69,7 +69,7 @@
       String subsection,
       String keyInterval,
       String keyStartTime,
-      DateTime now) {
+      ZonedDateTime now) {
     this.rc = rc;
     this.section = section;
     this.subsection = subsection;
@@ -122,31 +122,24 @@
       String section,
       String subsection,
       String keyStartTime,
-      DateTime now,
+      ZonedDateTime now,
       long interval) {
     long delay = MISSING_CONFIG;
     String start = rc.getString(section, subsection, keyStartTime);
     try {
       if (start != null) {
-        DateTimeFormatter formatter;
-        MutableDateTime startTime = now.toMutableDateTime();
+        DateTimeFormatter formatter =
+            DateTimeFormatter.ofPattern("[E ]HH:mm").withLocale(Locale.US);
+        LocalTime firstStartTime = LocalTime.parse(start, formatter);
+        ZonedDateTime startTime = now.with(firstStartTime);
         try {
-          formatter = ISODateTimeFormat.hourMinute();
-          LocalTime firstStartTime = formatter.parseLocalTime(start);
-          startTime.hourOfDay().set(firstStartTime.getHourOfDay());
-          startTime.minuteOfHour().set(firstStartTime.getMinuteOfHour());
-        } catch (IllegalArgumentException e1) {
-          formatter = DateTimeFormat.forPattern("E HH:mm").withLocale(Locale.US);
-          LocalDateTime firstStartDateTime = formatter.parseLocalDateTime(start);
-          startTime.dayOfWeek().set(firstStartDateTime.getDayOfWeek());
-          startTime.hourOfDay().set(firstStartDateTime.getHourOfDay());
-          startTime.minuteOfHour().set(firstStartDateTime.getMinuteOfHour());
+          DayOfWeek dayOfWeek = formatter.parse(start, DayOfWeek::from);
+          startTime = startTime.with(dayOfWeek);
+        } catch (DateTimeParseException ignored) {
+          // Day of week is an optional parameter.
         }
-        startTime.secondOfMinute().set(0);
-        startTime.millisOfSecond().set(0);
-        long s = startTime.getMillis();
-        long n = now.getMillis();
-        delay = (s - n) % interval;
+        startTime = startTime.truncatedTo(ChronoUnit.MINUTES);
+        delay = Duration.between(now, startTime).toMillis() % interval;
         if (delay <= 0) {
           delay += interval;
         }
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 afd78dc..2614eaf 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
@@ -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;
@@ -425,6 +426,8 @@
         p.insertions = patch.getInsertions();
         patchSetAttribute.files.add(p);
       }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Cannot get patch list: " + e.getMessage());
     } catch (PatchListNotAvailableException e) {
       log.warn("Cannot get patch list", e);
     }
@@ -498,6 +501,8 @@
       p.kind = changeKindCache.getChangeKind(db, change, patchSet);
     } catch (IOException | OrmException e) {
       log.error("Cannot load patch set data for " + patchSet.getId(), e);
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn(String.format("Cannot get size information for %s: %s", pId, e.getMessage()));
     } catch (PatchListNotAvailableException e) {
       log.error(String.format("Cannot get size information for %s.", pId), e);
     }
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 f9fc60a..ef69616 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 java.io.IOException;
@@ -70,7 +72,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 feaa54a..e9ae356 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 java.io.IOException;
@@ -64,7 +66,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 03a6f1f..c25deab 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 java.io.IOException;
@@ -63,7 +65,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 e76a032..77cd1a8 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 java.io.IOException;
@@ -74,7 +76,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/EventUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
index be308a9..95d7132 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,6 +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.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -80,12 +81,14 @@
   }
 
   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());
     return changeJson.getRevisionInfo(cd, ps);
   }
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 e4f8572..fc6881d 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 java.io.IOException;
@@ -67,7 +69,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 033efe2..28e07a9 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 java.io.IOException;
@@ -78,7 +80,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 8a781d0..76779ca 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 java.io.IOException;
@@ -64,7 +66,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 71a603c..8944698 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 java.io.IOException;
@@ -78,7 +80,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/git/BanCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommit.java
index 322d158..4991715 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
@@ -17,12 +17,15 @@
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_REJECT_COMMITS;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.common.errors.PermissionDeniedException;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.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.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -70,6 +73,7 @@
   private final Provider<IdentifiedUser> currentUser;
   private final GitRepositoryManager repoManager;
   private final TimeZone tz;
+  private final PermissionBackend permissionBackend;
   private NotesBranchUtil.Factory notesBranchUtilFactory;
 
   @Inject
@@ -77,24 +81,23 @@
       Provider<IdentifiedUser> currentUser,
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent gerritIdent,
-      NotesBranchUtil.Factory notesBranchUtilFactory) {
+      NotesBranchUtil.Factory notesBranchUtilFactory,
+      PermissionBackend permissionBackend) {
     this.currentUser = currentUser;
     this.repoManager = repoManager;
     this.notesBranchUtilFactory = notesBranchUtilFactory;
+    this.permissionBackend = permissionBackend;
     this.tz = gerritIdent.getTimeZone();
   }
 
   public BanCommitResult ban(
-      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");
-    }
+      Project.NameKey project, CurrentUser user, List<ObjectId> commitsToBan, String reason)
+      throws AuthException, LockFailureException, IOException, PermissionBackendException {
+    permissionBackend.user(user).project(project).check(ProjectPermission.BAN_COMMIT);
 
     final BanCommitResult result = new BanCommitResult();
     NoteMap banCommitNotes = NoteMap.newEmptyMap();
     // Add a note for each banned commit to notes.
-    final Project.NameKey project = projectControl.getProject().getNameKey();
     try (Repository repo = repoManager.openRepository(project);
         RevWalk revWalk = new RevWalk(repo);
         ObjectInserter inserter = repo.newObjectInserter()) {
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/HookUtil.java
similarity index 89%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/receive/HookUtil.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/git/HookUtil.java
index 90b220a..1762b95 100644
--- 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/HookUtil.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.git.receive;
+package com.google.gerrit.server.git;
 
 import java.io.IOException;
 import java.util.Map;
@@ -21,8 +21,8 @@
 import org.eclipse.jgit.transport.BaseReceivePack;
 import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 
-/** Static utilities for writing {@link ReceiveCommits}-related hooks. */
-class HookUtil {
+/** Static utilities for writing git protocol hooks. */
+public class HookUtil {
   /**
    * Scan and advertise all refs in the repo if refs have not already been advertised; otherwise,
    * just return the advertised map.
@@ -31,7 +31,7 @@
    * @return map of refs that were advertised.
    * @throws ServiceMayNotContinueException if a problem occurred.
    */
-  static Map<String, Ref> ensureAllRefsAdvertised(BaseReceivePack rp)
+  public static Map<String, Ref> ensureAllRefsAdvertised(BaseReceivePack rp)
       throws ServiceMayNotContinueException {
     Map<String, Ref> refs = rp.getAdvertisedRefs();
     if (refs != null) {
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 6a332cd..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,32 +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.extensions.restapi.AuthException;
 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.notedb.ChangeNotes;
-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.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 {
@@ -77,33 +71,24 @@
     }
   }
 
-  private final Provider<ReviewDb> db;
   private final IdentifiedUser.GenericFactory userFactory;
-  private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
 
   @Inject
-  LabelNormalizer(
-      Provider<ReviewDb> db,
-      IdentifiedUser.GenericFactory userFactory,
-      PermissionBackend permissionBackend,
-      ProjectCache projectCache) {
-    this.db = db;
+  LabelNormalizer(IdentifiedUser.GenericFactory userFactory, ProjectCache projectCache) {
     this.userFactory = userFactory;
-    this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
   }
 
   /**
    * @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(ChangeNotes notes, Collection<PatchSetApproval> approvals)
-      throws OrmException, PermissionBackendException, IOException {
+      throws OrmException, IOException {
     IdentifiedUser user = userFactory.create(notes.getChange().getOwner());
     return normalize(notes, user, approvals);
   }
@@ -112,13 +97,12 @@
    * @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(
       ChangeNotes notes, CurrentUser user, Collection<PatchSetApproval> approvals)
-      throws PermissionBackendException, IOException {
+      throws IOException {
     List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
     List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
     List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
@@ -142,9 +126,7 @@
       }
       PatchSetApproval copy = copy(psa);
       applyTypeFloor(label, copy);
-      if (!applyRightFloor(notes, label, copy)) {
-        deleted.add(psa);
-      } else if (copy.getValue() != psa.getValue()) {
+      if (copy.getValue() != psa.getValue()) {
         updated.add(copy);
       } else {
         unchanged.add(psa);
@@ -157,26 +139,6 @@
     return new PatchSetApproval(src.getPatchSetId(), src);
   }
 
-  private boolean applyRightFloor(ChangeNotes notes, LabelType lt, PatchSetApproval a)
-      throws PermissionBackendException {
-    PermissionBackend.ForChange forChange =
-        permissionBackend.user(userFactory.create(a.getAccountId())).database(db).change(notes);
-    // Check if the user is allowed to vote on the label at all
-    try {
-      forChange.check(new LabelPermission(lt.getName()));
-    } catch (AuthException e) {
-      return false;
-    }
-    // Squash vote to nearest allowed value
-    try {
-      forChange.check(new LabelPermission.WithValue(lt.getName(), a.getValue()));
-      return true;
-    } catch (AuthException e) {
-      a.setValue(forChange.squashThenCheck(lt, 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/LocalDiskRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 50f4975..23f2526 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
@@ -34,8 +34,6 @@
 import java.util.EnumSet;
 import java.util.SortedSet;
 import java.util.TreeSet;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ConfigConstants;
@@ -111,8 +109,6 @@
   }
 
   private final Path basePath;
-  private final Lock namesUpdateLock;
-  private volatile SortedSet<Project.NameKey> names = new TreeSet<>();
 
   @Inject
   LocalDiskRepositoryManager(SitePaths site, @GerritServerConfig Config cfg) {
@@ -120,8 +116,6 @@
     if (basePath == null) {
       throw new IllegalStateException("gerrit.basePath must be configured");
     }
-
-    namesUpdateLock = new ReentrantLock(true /* fair */);
   }
 
   /**
@@ -144,32 +138,7 @@
     if (isUnreasonableName(name)) {
       throw new RepositoryNotFoundException("Invalid name: " + name);
     }
-    File gitDir = path.resolve(name.get()).toFile();
-    if (!names.contains(name)) {
-      // The this.names list does not hold the project-name but it can still exist
-      // on disk; for instance when the project has been created directly on the
-      // file-system through replication.
-      //
-      if (!name.get().endsWith(Constants.DOT_GIT_EXT)) {
-        if (FileKey.resolve(gitDir, FS.DETECTED) != null) {
-          onCreateProject(name);
-        } else {
-          throw new RepositoryNotFoundException(gitDir);
-        }
-      } else {
-        final File directory = gitDir;
-        if (FileKey.isGitRepository(new File(directory, Constants.DOT_GIT), FS.DETECTED)) {
-          onCreateProject(name);
-        } else if (FileKey.isGitRepository(
-            new File(directory.getParentFile(), directory.getName() + Constants.DOT_GIT_EXT),
-            FS.DETECTED)) {
-          onCreateProject(name);
-        } else {
-          throw new RepositoryNotFoundException(gitDir);
-        }
-      }
-    }
-    final FileKey loc = FileKey.lenient(gitDir, FS.DETECTED);
+    FileKey loc = FileKey.lenient(path.resolve(name.get()).toFile(), FS.DETECTED);
     try {
       return RepositoryCache.open(loc);
     } catch (IOException e1) {
@@ -189,26 +158,24 @@
     }
 
     File dir = FileKey.resolve(path.resolve(name.get()).toFile(), FS.DETECTED);
-    FileKey loc;
     if (dir != null) {
       // Already exists on disk, use the repository we found.
       //
       Project.NameKey onDiskName = getProjectName(path, dir.getCanonicalFile().toPath());
-      onCreateProject(onDiskName);
 
-      loc = FileKey.exact(dir, FS.DETECTED);
-
-      if (!names.contains(name)) {
+      if (!onDiskName.equals(name)) {
         throw new RepositoryCaseMismatchException(name);
       }
-    } else {
-      // It doesn't exist under any of the standard permutations
-      // of the repository name, so prefer the standard bare name.
-      //
-      String n = name.get() + Constants.DOT_GIT_EXT;
-      loc = FileKey.exact(path.resolve(n).toFile(), FS.DETECTED);
+
+      throw new IllegalStateException("Repository already exists: " + name);
     }
 
+    // It doesn't exist under any of the standard permutations
+    // of the repository name, so prefer the standard bare name.
+    //
+    String n = name.get() + Constants.DOT_GIT_EXT;
+    FileKey loc = FileKey.exact(path.resolve(n).toFile(), FS.DETECTED);
+
     try {
       Repository db = RepositoryCache.open(loc, false);
       db.create(true /* bare */);
@@ -231,8 +198,6 @@
                 "Failed to create ref log for %s in repository %s", RefNames.REFS_CONFIG, name));
       }
 
-      onCreateProject(name);
-
       return db;
     } catch (IOException e1) {
       final RepositoryNotFoundException e2;
@@ -242,17 +207,6 @@
     }
   }
 
-  private void onCreateProject(Project.NameKey newProjectName) {
-    namesUpdateLock.lock();
-    try {
-      SortedSet<Project.NameKey> n = new TreeSet<>(names);
-      n.add(newProjectName);
-      names = Collections.unmodifiableSortedSet(n);
-    } finally {
-      namesUpdateLock.unlock();
-    }
-  }
-
   private boolean isUnreasonableName(Project.NameKey nameKey) {
     final String name = nameKey.get();
 
@@ -281,21 +235,9 @@
 
   @Override
   public SortedSet<Project.NameKey> list() {
-    // The results of this method are cached by ProjectCacheImpl. Control only
-    // enters here if the cache was flushed by the administrator to force
-    // scanning the filesystem.
-    // Don't rely on the cached names collection but update it to contain
-    // the set of found project names
     ProjectVisitor visitor = new ProjectVisitor(basePath);
     scanProjects(visitor);
-
-    namesUpdateLock.lock();
-    try {
-      names = Collections.unmodifiableSortedSet(visitor.found);
-    } finally {
-      namesUpdateLock.unlock();
-    }
-    return names;
+    return Collections.unmodifiableSortedSet(visitor.found);
   }
 
   protected void scanProjects(ProjectVisitor visitor) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalMergeSuperSetComputation.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalMergeSuperSetComputation.java
new file mode 100644
index 0000000..e681145
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalMergeSuperSetComputation.java
@@ -0,0 +1,260 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.registration.DynamicItem;
+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.git.MergeOpRepoManager.OpenRepo;
+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.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.AbstractModule;
+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.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Default implementation of MergeSuperSet that does the computation of the merge super set
+ * sequentially on the local Gerrit instance.
+ */
+public class LocalMergeSuperSetComputation implements MergeSuperSetComputation {
+  private static final Logger log = LoggerFactory.getLogger(LocalMergeSuperSetComputation.class);
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      DynamicItem.bind(binder(), MergeSuperSetComputation.class)
+          .to(LocalMergeSuperSetComputation.class);
+    }
+  }
+
+  @AutoValue
+  abstract static class QueryKey {
+    private static QueryKey create(Branch.NameKey branch, Iterable<String> hashes) {
+      return new AutoValue_LocalMergeSuperSetComputation_QueryKey(
+          branch, ImmutableSet.copyOf(hashes));
+    }
+
+    abstract Branch.NameKey branch();
+
+    abstract ImmutableSet<String> hashes();
+  }
+
+  private final PermissionBackend permissionBackend;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Map<QueryKey, List<ChangeData>> queryCache;
+  private final Map<Branch.NameKey, Optional<RevCommit>> heads;
+
+  @Inject
+  LocalMergeSuperSetComputation(
+      PermissionBackend permissionBackend, Provider<InternalChangeQuery> queryProvider) {
+    this.permissionBackend = permissionBackend;
+    this.queryProvider = queryProvider;
+    this.queryCache = new HashMap<>();
+    this.heads = new HashMap<>();
+  }
+
+  @Override
+  public ChangeSet completeWithoutTopic(
+      ReviewDb db, MergeOpRepoManager orm, ChangeSet changeSet, CurrentUser user)
+      throws OrmException, IOException, PermissionBackendException {
+    Collection<ChangeData> visibleChanges = new ArrayList<>();
+    Collection<ChangeData> nonVisibleChanges = new ArrayList<>();
+
+    // For each target branch we run a separate rev walk to find open changes
+    // reachable from changes already in the merge super set.
+    ImmutableListMultimap<Branch.NameKey, ChangeData> bc =
+        byBranch(Iterables.concat(changeSet.changes(), changeSet.nonVisibleChanges()));
+    for (Branch.NameKey b : bc.keySet()) {
+      OpenRepo or = getRepo(orm, b.getParentKey());
+      List<RevCommit> visibleCommits = new ArrayList<>();
+      List<RevCommit> nonVisibleCommits = new ArrayList<>();
+      for (ChangeData cd : bc.get(b)) {
+        boolean visible = isVisible(db, changeSet, cd, user);
+
+        if (submitType(cd) == SubmitType.CHERRY_PICK) {
+          if (visible) {
+            visibleChanges.add(cd);
+          } else {
+            nonVisibleChanges.add(cd);
+          }
+
+          continue;
+        }
+
+        // Get the underlying git commit object
+        String objIdStr = cd.currentPatchSet().getRevision().get();
+        RevCommit commit = or.rw.parseCommit(ObjectId.fromString(objIdStr));
+
+        // Always include the input, even if merged. This allows
+        // SubmitStrategyOp to correct the situation later, assuming it gets
+        // returned by byCommitsOnBranchNotMerged below.
+        if (visible) {
+          visibleCommits.add(commit);
+        } else {
+          nonVisibleCommits.add(commit);
+        }
+      }
+
+      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, b, nonVisibleHashes));
+    }
+
+    return new ChangeSet(visibleChanges, nonVisibleChanges);
+  }
+
+  private static ImmutableListMultimap<Branch.NameKey, ChangeData> byBranch(
+      Iterable<ChangeData> changes) throws OrmException {
+    ImmutableListMultimap.Builder<Branch.NameKey, ChangeData> builder =
+        ImmutableListMultimap.builder();
+    for (ChangeData cd : changes) {
+      builder.put(cd.change().getDest(), cd);
+    }
+    return builder.build();
+  }
+
+  private OpenRepo getRepo(MergeOpRepoManager orm, Project.NameKey project) throws IOException {
+    try {
+      OpenRepo or = orm.getRepo(project);
+      checkState(or.rw.hasRevSort(RevSort.TOPO));
+      return or;
+    } catch (NoSuchProjectException e) {
+      throw new IOException(e);
+    }
+  }
+
+  private boolean isVisible(ReviewDb db, ChangeSet changeSet, ChangeData cd, CurrentUser user)
+      throws PermissionBackendException {
+    boolean visible = changeSet.ids().contains(cd.getId());
+    if (visible
+        && !permissionBackend.user(user).change(cd).database(db).test(ChangePermission.READ)) {
+      // 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;
+    }
+    return visible;
+  }
+
+  private SubmitType submitType(ChangeData cd) throws OrmException {
+    SubmitTypeRecord str = cd.submitTypeRecord();
+    if (!str.isOk()) {
+      logErrorAndThrow("Failed to get submit type for " + cd.getId() + ": " + str.errorMessage);
+    }
+    return str.type;
+  }
+
+  private List<ChangeData> byCommitsOnBranchNotMerged(
+      OpenRepo or, ReviewDb db, Branch.NameKey branch, Set<String> hashes)
+      throws OrmException, IOException {
+    if (hashes.isEmpty()) {
+      return ImmutableList.of();
+    }
+    QueryKey k = QueryKey.create(branch, hashes);
+    List<ChangeData> cached = queryCache.get(k);
+    if (cached != null) {
+      return cached;
+    }
+
+    List<ChangeData> result = new ArrayList<>();
+    Iterable<ChangeData> destChanges =
+        MergeSuperSet.query(queryProvider.get())
+            .byCommitsOnBranchNotMerged(or.repo, db, branch, hashes);
+    for (ChangeData chd : destChanges) {
+      result.add(chd);
+    }
+    queryCache.put(k, result);
+    return result;
+  }
+
+  private Set<String> walkChangesByHashes(
+      Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, Branch.NameKey b)
+      throws IOException {
+    Set<String> destHashes = new HashSet<>();
+    or.rw.reset();
+    markHeadUninteresting(or, b);
+    for (RevCommit c : sourceCommits) {
+      String name = c.name();
+      if (ignoreHashes.contains(name)) {
+        continue;
+      }
+      destHashes.add(name);
+      or.rw.markStart(c);
+    }
+    for (RevCommit c : or.rw) {
+      String name = c.name();
+      if (ignoreHashes.contains(name)) {
+        continue;
+      }
+      destHashes.add(name);
+    }
+
+    return destHashes;
+  }
+
+  private void markHeadUninteresting(OpenRepo or, Branch.NameKey b) throws IOException {
+    Optional<RevCommit> head = heads.get(b);
+    if (head == null) {
+      Ref ref = or.repo.getRefDatabase().exactRef(b.get());
+      head = ref != null ? Optional.of(or.rw.parseCommit(ref.getObjectId())) : Optional.empty();
+      heads.put(b, head);
+    }
+    if (head.isPresent()) {
+      or.rw.markUninteresting(head.get());
+    }
+  }
+
+  private void logErrorAndThrow(String msg) throws OrmException {
+    if (log.isErrorEnabled()) {
+      log.error(msg);
+    }
+    throw new OrmException(msg);
+  }
+}
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 4e0c3ae..b473d20 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
@@ -17,31 +17,18 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.SubmitTypeRecord;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.extensions.registration.DynamicItem;
 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.change.Submit;
 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.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -49,21 +36,10 @@
 import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
-import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevSort;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  * Calculates the minimal superset of changes required to be merged.
@@ -76,38 +52,36 @@
  * included.
  */
 public class MergeSuperSet {
-  private static final Logger log = LoggerFactory.getLogger(MergeSuperSet.class);
 
-  public static void reloadChanges(ChangeSet cs) throws OrmException {
-    // Clear exactly the fields requested by query() below.
-    for (ChangeData cd : cs.changes()) {
+  public static void reloadChanges(ChangeSet changeSet) throws OrmException {
+    // Clear exactly the fields requested by query(InternalChangeQuery) below.
+    for (ChangeData cd : changeSet.changes()) {
       cd.reloadChange();
       cd.setPatchSets(null);
       cd.setMergeable(null);
     }
   }
 
-  @AutoValue
-  abstract static class QueryKey {
-    private static QueryKey create(Branch.NameKey branch, Iterable<String> hashes) {
-      return new AutoValue_MergeSuperSet_QueryKey(branch, ImmutableSet.copyOf(hashes));
-    }
-
-    abstract Branch.NameKey branch();
-
-    abstract ImmutableSet<String> hashes();
+  public static InternalChangeQuery query(InternalChangeQuery q) {
+    // Request fields required for completing the ChangeSet and converting to
+    // ChangeInfo without having to touch the database or opening the repository
+    // more than necessary. This provides reasonable performance when loading
+    // the change screen; callers that care about reading the latest value of
+    // these fields should clear them explicitly using reloadChanges().
+    Set<String> fields =
+        ImmutableSet.of(
+            ChangeField.CHANGE.getName(),
+            ChangeField.PATCH_SET.getName(),
+            ChangeField.MERGEABLE.getName());
+    return q.setRequestedFields(fields);
   }
 
   private final ChangeData.Factory changeDataFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Provider<MergeOpRepoManager> repoManagerProvider;
+  private final DynamicItem<MergeSuperSetComputation> mergeSuperSetComputation;
   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 final ChangeControl.GenericFactory changeControlFactory;
-  private final ProjectCache projectCache;
 
   private MergeOpRepoManager orm;
   private boolean closeOrm;
@@ -118,20 +92,14 @@
       ChangeData.Factory changeDataFactory,
       Provider<InternalChangeQuery> queryProvider,
       Provider<MergeOpRepoManager> repoManagerProvider,
-      PermissionBackend permissionBackend,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
-      ChangeControl.GenericFactory changeControlFactory,
-      ProjectCache projectCache) {
+      DynamicItem<MergeSuperSetComputation> mergeSuperSetComputation,
+      PermissionBackend permissionBackend) {
     this.cfg = cfg;
     this.changeDataFactory = changeDataFactory;
     this.queryProvider = queryProvider;
     this.repoManagerProvider = repoManagerProvider;
+    this.mergeSuperSetComputation = mergeSuperSetComputation;
     this.permissionBackend = permissionBackend;
-    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
-    this.changeControlFactory = changeControlFactory;
-    this.projectCache = projectCache;
-    queryCache = new HashMap<>();
-    heads = new HashMap<>();
   }
 
   public MergeSuperSet setMergeOpRepoManager(MergeOpRepoManager orm) {
@@ -144,14 +112,19 @@
   public ChangeSet completeChangeSet(ReviewDb db, Change change, CurrentUser user)
       throws IOException, OrmException, PermissionBackendException {
     try {
+      if (orm == null) {
+        orm = repoManagerProvider.get();
+        closeOrm = true;
+      }
+
       ChangeData cd = changeDataFactory.create(db, change.getProject(), change.getId());
-      ChangeSet cs =
+      ChangeSet changeSet =
           new ChangeSet(
               cd, permissionBackend.user(user).change(cd).database(db).test(ChangePermission.READ));
       if (Submit.wholeTopicEnabled(cfg)) {
-        return completeChangeSetIncludingTopics(db, cs, user);
+        return completeChangeSetIncludingTopics(db, changeSet, user);
       }
-      return completeChangeSetWithoutTopic(db, cs, user);
+      return mergeSuperSetComputation.get().completeWithoutTopic(db, orm, changeSet, user);
     } finally {
       if (closeOrm && orm != null) {
         orm.close();
@@ -160,193 +133,13 @@
     }
   }
 
-  private SubmitType submitType(CurrentUser user, ChangeData cd, PatchSet ps, boolean visible)
-      throws OrmException, IOException {
-    // Submit type prolog rules mean that the submit type can depend on the
-    // submitting user and the content of the change.
-    //
-    // If the current user can see the change, run that evaluation to get a
-    // preview of what would happen on submit.  If the current user can't see
-    // the change, instead of guessing who would do the submitting, rely on the
-    // project configuration and ignore the prolog rule.  If the prolog rule
-    // doesn't match that, we may pick the wrong submit type and produce a
-    // misleading (but still nonzero) count of the non visible changes that
-    // would be submitted together with the visible ones.
-    if (!visible) {
-      return projectCache.checkedGet(cd.project()).getProject().getSubmitType();
-    }
-
-    SubmitTypeRecord str =
-        ps == cd.currentPatchSet()
-            ? cd.submitTypeRecord()
-            : submitRuleEvaluatorFactory.create(user, cd).setPatchSet(ps).getSubmitType();
-    if (!str.isOk()) {
-      logErrorAndThrow("Failed to get submit type for " + cd.getId() + ": " + str.errorMessage);
-    }
-    return str.type;
-  }
-
-  private static ImmutableListMultimap<Branch.NameKey, ChangeData> byBranch(
-      Iterable<ChangeData> changes) throws OrmException {
-    ImmutableListMultimap.Builder<Branch.NameKey, ChangeData> builder =
-        ImmutableListMultimap.builder();
-    for (ChangeData cd : changes) {
-      builder.put(cd.change().getDest(), cd);
-    }
-    return builder.build();
-  }
-
-  private Set<String> walkChangesByHashes(
-      Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, Branch.NameKey b)
-      throws IOException {
-    Set<String> destHashes = new HashSet<>();
-    or.rw.reset();
-    markHeadUninteresting(or, b);
-    for (RevCommit c : sourceCommits) {
-      String name = c.name();
-      if (ignoreHashes.contains(name)) {
-        continue;
-      }
-      destHashes.add(name);
-      or.rw.markStart(c);
-    }
-    for (RevCommit c : or.rw) {
-      String name = c.name();
-      if (ignoreHashes.contains(name)) {
-        continue;
-      }
-      destHashes.add(name);
-    }
-
-    return destHashes;
-  }
-
-  private ChangeSet completeChangeSetWithoutTopic(ReviewDb db, ChangeSet changes, CurrentUser user)
-      throws IOException, OrmException, PermissionBackendException {
-    Collection<ChangeData> visibleChanges = new ArrayList<>();
-    Collection<ChangeData> nonVisibleChanges = new ArrayList<>();
-
-    // For each target branch we run a separate rev walk to find open changes
-    // reachable from changes already in the merge super set.
-    ImmutableListMultimap<Branch.NameKey, ChangeData> bc =
-        byBranch(Iterables.concat(changes.changes(), changes.nonVisibleChanges()));
-    for (Branch.NameKey b : bc.keySet()) {
-      OpenRepo or = getRepo(b.getParentKey());
-      List<RevCommit> visibleCommits = new ArrayList<>();
-      List<RevCommit> nonVisibleCommits = new ArrayList<>();
-      for (ChangeData cd : bc.get(b)) {
-        boolean visible = changes.ids().contains(cd.getId());
-        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;
-        }
-
-        // 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;
-        ChangeControl ctl = changeControlFactory.controlFor(cd.notes(), user);
-        if (!ctl.isPatchVisible(ps, cd)) {
-          Iterable<PatchSet> visiblePatchSets = ctl.getVisiblePatchSets(cd.patchSets(), db);
-          if (Iterables.isEmpty(visiblePatchSets)) {
-            visiblePatchSet = false;
-          } else {
-            ps = Iterables.getLast(visiblePatchSets);
-          }
-        }
-
-        if (submitType(user, cd, ps, visiblePatchSet) == SubmitType.CHERRY_PICK) {
-          if (visible) {
-            visibleChanges.add(cd);
-          } else {
-            nonVisibleChanges.add(cd);
-          }
-
-          continue;
-        }
-
-        // Get the underlying git commit object
-        String objIdStr = ps.getRevision().get();
-        RevCommit commit = or.rw.parseCommit(ObjectId.fromString(objIdStr));
-
-        // Always include the input, even if merged. This allows
-        // SubmitStrategyOp to correct the situation later, assuming it gets
-        // returned by byCommitsOnBranchNotMerged below.
-        if (visible) {
-          visibleCommits.add(commit);
-        } else {
-          nonVisibleCommits.add(commit);
-        }
-      }
-
-      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, b, nonVisibleHashes));
-    }
-
-    return new ChangeSet(visibleChanges, nonVisibleChanges);
-  }
-
-  private OpenRepo getRepo(Project.NameKey project) throws IOException {
-    if (orm == null) {
-      orm = repoManagerProvider.get();
-      closeOrm = true;
-    }
-    try {
-      OpenRepo or = orm.getRepo(project);
-      checkState(or.rw.hasRevSort(RevSort.TOPO));
-      return or;
-    } catch (NoSuchProjectException e) {
-      throw new IOException(e);
-    }
-  }
-
-  private void markHeadUninteresting(OpenRepo or, Branch.NameKey b) throws IOException {
-    Optional<RevCommit> head = heads.get(b);
-    if (head == null) {
-      Ref ref = or.repo.getRefDatabase().exactRef(b.get());
-      head = ref != null ? Optional.of(or.rw.parseCommit(ref.getObjectId())) : Optional.empty();
-      heads.put(b, head);
-    }
-    if (head.isPresent()) {
-      or.rw.markUninteresting(head.get());
-    }
-  }
-
-  private List<ChangeData> byCommitsOnBranchNotMerged(
-      OpenRepo or, ReviewDb db, Branch.NameKey branch, Set<String> hashes)
-      throws OrmException, IOException {
-    if (hashes.isEmpty()) {
-      return ImmutableList.of();
-    }
-    QueryKey k = QueryKey.create(branch, hashes);
-    List<ChangeData> cached = queryCache.get(k);
-    if (cached != null) {
-      return cached;
-    }
-
-    List<ChangeData> result = new ArrayList<>();
-    Iterable<ChangeData> destChanges =
-        query().byCommitsOnBranchNotMerged(or.repo, db, branch, hashes);
-    for (ChangeData chd : destChanges) {
-      result.add(chd);
-    }
-    queryCache.put(k, result);
-    return result;
-  }
-
   /**
-   * Completes {@code cs} with any additional changes from its topics
+   * Completes {@code changeSet} with any additional changes from its topics
    *
    * <p>{@link #completeChangeSetIncludingTopics} calls this repeatedly, alternating with {@link
-   * #completeChangeSetWithoutTopic}, to discover what additional changes should be submitted with a
-   * change until the set stops growing.
+   * MergeSuperSetComputation#completeWithoutTopic(ReviewDb, MergeOpRepoManager, ChangeSet,
+   * CurrentUser)}, to discover what additional changes should be submitted with a change until the
+   * set stops growing.
    *
    * <p>{@code topicsSeen} and {@code visibleTopicsSeen} keep track of topics already explored to
    * avoid wasted work.
@@ -355,7 +148,7 @@
    */
   private ChangeSet topicClosure(
       ReviewDb db,
-      ChangeSet cs,
+      ChangeSet changeSet,
       CurrentUser user,
       Set<String> topicsSeen,
       Set<String> visibleTopicsSeen)
@@ -363,13 +156,13 @@
     List<ChangeData> visibleChanges = new ArrayList<>();
     List<ChangeData> nonVisibleChanges = new ArrayList<>();
 
-    for (ChangeData cd : cs.changes()) {
+    for (ChangeData cd : changeSet.changes()) {
       visibleChanges.add(cd);
       String topic = cd.change().getTopic();
       if (Strings.isNullOrEmpty(topic) || visibleTopicsSeen.contains(topic)) {
         continue;
       }
-      for (ChangeData topicCd : query().byTopicOpen(topic)) {
+      for (ChangeData topicCd : byTopicOpen(topic)) {
         if (canRead(db, user, topicCd)) {
           visibleChanges.add(topicCd);
         } else {
@@ -379,13 +172,13 @@
       topicsSeen.add(topic);
       visibleTopicsSeen.add(topic);
     }
-    for (ChangeData cd : cs.nonVisibleChanges()) {
+    for (ChangeData cd : changeSet.nonVisibleChanges()) {
       nonVisibleChanges.add(cd);
       String topic = cd.change().getTopic();
       if (Strings.isNullOrEmpty(topic) || topicsSeen.contains(topic)) {
         continue;
       }
-      for (ChangeData topicCd : query().byTopicOpen(topic)) {
+      for (ChangeData topicCd : byTopicOpen(topic)) {
         nonVisibleChanges.add(topicCd);
       }
       topicsSeen.add(topic);
@@ -394,47 +187,27 @@
   }
 
   private ChangeSet completeChangeSetIncludingTopics(
-      ReviewDb db, ChangeSet changes, CurrentUser user)
+      ReviewDb db, ChangeSet changeSet, CurrentUser user)
       throws IOException, OrmException, PermissionBackendException {
     Set<String> topicsSeen = new HashSet<>();
     Set<String> visibleTopicsSeen = new HashSet<>();
     int oldSeen;
     int seen = 0;
 
+    changeSet = topicClosure(db, changeSet, user, topicsSeen, visibleTopicsSeen);
+    seen = topicsSeen.size() + visibleTopicsSeen.size();
+
     do {
       oldSeen = seen;
-
-      changes = completeChangeSetWithoutTopic(db, changes, user);
-      changes = topicClosure(db, changes, user, topicsSeen, visibleTopicsSeen);
-
+      changeSet = mergeSuperSetComputation.get().completeWithoutTopic(db, orm, changeSet, user);
+      changeSet = topicClosure(db, changeSet, user, topicsSeen, visibleTopicsSeen);
       seen = topicsSeen.size() + visibleTopicsSeen.size();
     } while (seen != oldSeen);
-    return changes;
+    return changeSet;
   }
 
-  private InternalChangeQuery query() {
-    // Request fields required for completing the ChangeSet and converting to
-    // ChangeInfo without having to touch the database or opening the repository
-    // more than necessary. This provides reasonable performance when loading
-    // the change screen; callers that care about reading the latest value of
-    // these fields should clear them explicitly using reloadChanges().
-    Set<String> fields =
-        ImmutableSet.of(
-            ChangeField.CHANGE.getName(),
-            ChangeField.PATCH_SET.getName(),
-            ChangeField.MERGEABLE.getName());
-    return queryProvider.get().setRequestedFields(fields);
-  }
-
-  private void logError(String msg) {
-    if (log.isErrorEnabled()) {
-      log.error(msg);
-    }
-  }
-
-  private void logErrorAndThrow(String msg) throws OrmException {
-    logError(msg);
-    throw new OrmException(msg);
+  private List<ChangeData> byTopicOpen(String topic) throws OrmException {
+    return query(queryProvider.get()).byTopicOpen(topic);
   }
 
   private boolean canRead(ReviewDb db, CurrentUser user, ChangeData cd)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSetComputation.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSetComputation.java
new file mode 100644
index 0000000..63405ba
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSetComputation.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+
+/**
+ * Interface to compute the merge super set to detect changes that should be submitted together.
+ *
+ * <p>E.g. to speed up performance implementations could decide to do the computation in batches in
+ * parallel on different server nodes.
+ */
+@ExtensionPoint
+public interface MergeSuperSetComputation {
+
+  /**
+   * Compute the set of changes that should be submitted together. As input a set of changes is
+   * provided for which it is known that they should be submitted together. This method should
+   * complete the set by including open predecessor changes that need to be submitted as well. To
+   * decide whether open predecessor changes should be included the method must take the submit type
+   * into account (e.g. for changes with submit type "Cherry-Pick" open predecessor changes must not
+   * be included).
+   *
+   * <p>This method is invoked iteratively while new changes to be submitted together are discovered
+   * by expanding the topics of the changes. This method must not do any topic expansion on its own.
+   *
+   * @param db {@link ReviewDb} instance
+   * @param orm {@link MergeOpRepoManager} that should be used to access repositories
+   * @param changeSet A set of changes for which it is known that they should be submitted together
+   * @param user The user for which the visibility checks should be performed
+   * @return the completed set of changes that should be submitted together
+   */
+  ChangeSet completeWithoutTopic(
+      ReviewDb db, MergeOpRepoManager orm, ChangeSet changeSet, CurrentUser user)
+      throws OrmException, IOException, PermissionBackendException;
+}
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 5ee9c45..bb811cd 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;
@@ -67,6 +66,7 @@
 import java.util.List;
 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;
@@ -156,9 +156,6 @@
   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";
@@ -868,19 +865,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);
@@ -1367,7 +1365,7 @@
       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(
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 2dd5f2a..ed86a92 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
@@ -38,7 +38,6 @@
 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;
@@ -80,7 +79,6 @@
   private final PermissionBackend.ForProject perm;
   private final ProjectState projectState;
   private final Repository git;
-  private ProjectControl projectCtl;
   private boolean showMetadata = true;
   private String userEditPrefix;
   private Map<Change.Id, Branch.NameKey> visibleChanges;
@@ -141,7 +139,6 @@
     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;
@@ -263,10 +260,20 @@
     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());
+    if (id == null) {
+      return false;
+    }
+    if (userEditPrefix != null && name.startsWith(userEditPrefix) && visible(id)) {
+      return true;
+    }
+    if (visibleChanges.containsKey(id)) {
+      try {
+        // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
+        perm.ref(visibleChanges.get(id).get()).check(RefPermission.READ_PRIVATE_CHANGES);
+        return true;
+      } catch (PermissionBackendException | AuthException e) {
+        return false;
+      }
     }
     return false;
   }
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
index 4afaacd..c092c43 100644
--- 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
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.gerrit.server.git.HookUtil;
 import java.util.Map;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.transport.AdvertiseRefsHook;
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
index 22834f3..6eba282 100644
--- 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
@@ -33,7 +33,6 @@
 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;
@@ -73,7 +72,8 @@
 
   public interface Factory {
     AsyncReceiveCommits create(
-        ProjectControl projectControl,
+        ProjectState projectState,
+        IdentifiedUser user,
         Repository repository,
         @Nullable MessageSender messageSender,
         SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers);
@@ -106,7 +106,7 @@
 
     private Worker(Collection<ReceiveCommand> commands) {
       this.commands = commands;
-      rc = factory.create(projectControl, rp, allRefsWatcher, extraReviewers);
+      rc = factory.create(projectState, user, rp, allRefsWatcher, extraReviewers);
       rc.init();
       rc.setMessageSender(messageSender);
       progress = new MultiProgressMonitor(new MessageSenderOutputStream(), "Processing changes");
@@ -165,13 +165,15 @@
   }
 
   private final ReceiveCommits.Factory factory;
+  private final PermissionBackend.ForProject perm;
   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 ProjectState projectState;
+  private final IdentifiedUser user;
   private final Repository repo;
   private final MessageSender messageSender;
   private final SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers;
@@ -190,7 +192,8 @@
       Provider<LazyPostReceiveHookChain> lazyPostReceive,
       ContributorAgreementsChecker contributorAgreements,
       @Named(TIMEOUT_NAME) long timeoutMillis,
-      @Assisted ProjectControl projectControl,
+      @Assisted ProjectState projectState,
+      @Assisted IdentifiedUser user,
       @Assisted Repository repo,
       @Assisted @Nullable MessageSender messageSender,
       @Assisted SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers)
@@ -201,22 +204,21 @@
     this.receiveConfig = receiveConfig;
     this.contributorAgreements = contributorAgreements;
     this.timeoutMillis = timeoutMillis;
-    this.projectControl = projectControl;
+    this.projectState = projectState;
+    this.user = user;
     this.repo = repo;
     this.messageSender = messageSender;
     this.extraReviewers = extraReviewers;
 
-    IdentifiedUser user = projectControl.getUser().asIdentifiedUser();
-    ProjectState state = projectControl.getProjectState();
-    Project.NameKey projectName = projectControl.getProject().getNameKey();
+    Project.NameKey projectName = projectState.getNameKey();
     rp = new ReceivePack(repo);
     rp.setAllowCreates(true);
     rp.setAllowDeletes(true);
     rp.setAllowNonFastForwards(true);
     rp.setRefLogIdent(user.newRefLogIdent());
     rp.setTimeout(transferConfig.getTimeout());
-    rp.setMaxObjectSizeLimit(transferConfig.getEffectiveMaxObjectSizeLimit(state));
-    rp.setCheckReceivedObjects(state.getConfig().getCheckReceivedObjects());
+    rp.setMaxObjectSizeLimit(transferConfig.getEffectiveMaxObjectSizeLimit(projectState));
+    rp.setCheckReceivedObjects(projectState.getConfig().getCheckReceivedObjects());
     rp.setRefFilter(new ReceiveRefFilter());
     rp.setAllowPushOptions(true);
     rp.setPreReceiveHook(this);
@@ -224,8 +226,9 @@
 
     // 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.
+    this.perm = permissionBackend.user(user).project(projectName);
     try {
-      permissionBackend.user(user).project(projectName).check(ProjectPermission.READ);
+      this.perm.check(ProjectPermission.READ);
     } catch (AuthException e) {
       rp.setCheckReferencedObjectsAreReachable(receiveConfig.checkReferencedObjectsAreReachable);
     }
@@ -233,28 +236,28 @@
     List<AdvertiseRefsHook> advHooks = new ArrayList<>(4);
     allRefsWatcher = new AllRefsWatcher();
     advHooks.add(allRefsWatcher);
-    advHooks.add(refFilterFactory.create(state, repo).setShowMetadata(false));
+    advHooks.add(refFilterFactory.create(projectState, repo).setShowMetadata(false));
     advHooks.add(new ReceiveCommitsAdvertiseRefsHook(queryProvider, projectName));
     advHooks.add(new HackPushNegotiateHook());
     rp.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
   }
 
   /** Determine if the user can upload commits. */
-  public Capable canUpload() throws IOException {
-    Capable result = projectControl.canPushToAtLeastOneRef();
-    if (result != Capable.OK) {
-      return result;
+  public Capable canUpload() throws IOException, PermissionBackendException {
+    try {
+      perm.check(ProjectPermission.PUSH_AT_LEAST_ONE_REF);
+    } catch (AuthException e) {
+      return new Capable("Upload denied for project '" + projectState.getName() + "'");
     }
 
     try {
-      contributorAgreements.check(
-          projectControl.getProject().getNameKey(), projectControl.getUser());
+      contributorAgreements.check(projectState.getNameKey(), user);
     } catch (AuthException e) {
       return new Capable(e.getMessage());
     }
 
     if (receiveConfig.checkMagicRefs) {
-      return MagicBranch.checkMagicBranchRefs(repo, projectControl.getProject());
+      return MagicBranch.checkMagicBranchRefs(repo, projectState.getProject());
     }
     return Capable.OK;
   }
@@ -269,7 +272,7 @@
       log.warn(
           String.format(
               "Error in ReceiveCommits while processing changes for project %s",
-              projectControl.getProject().getName()),
+              projectState.getName()),
           e);
       rp.sendError("internal error while processing changes");
       // ReceiveCommits has tried its best to catch errors, so anything at this
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
index f88dc81..a0e1402 100644
--- 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
@@ -128,12 +128,12 @@
 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.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;
@@ -229,7 +229,8 @@
 
   interface Factory {
     ReceiveCommits create(
-        ProjectControl projectControl,
+        ProjectState projectState,
+        IdentifiedUser user,
         ReceivePack receivePack,
         AllRefsWatcher allRefsWatcher,
         SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers);
@@ -301,7 +302,6 @@
   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;
@@ -326,7 +326,8 @@
   // Assisted injected fields.
   private final AllRefsWatcher allRefsWatcher;
   private final ImmutableSetMultimap<ReviewerStateInternal, Account.Id> extraReviewers;
-  private final ProjectControl projectControl;
+  private final ProjectState projectState;
+  private final IdentifiedUser user;
   private final ReceivePack rp;
 
   // Immutable fields derived from constructor arguments.
@@ -407,7 +408,8 @@
       TagCache tagCache,
       CreateRefControl createRefControl,
       DynamicItem<ChangeReportFormatter> changeFormatterProvider,
-      @Assisted ProjectControl projectControl,
+      @Assisted ProjectState projectState,
+      @Assisted IdentifiedUser user,
       @Assisted ReceivePack rp,
       @Assisted AllRefsWatcher allRefsWatcher,
       @Assisted SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers)
@@ -420,7 +422,6 @@
     this.changeInserterFactory = changeInserterFactory;
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.changeFormatter = changeFormatterProvider.get();
-    this.user = projectControl.getUser().asIdentifiedUser();
     this.db = db;
     this.editUtil = editUtil;
     this.hashtagsFactory = hashtagsFactory;
@@ -451,13 +452,14 @@
     // Assisted injected fields.
     this.allRefsWatcher = allRefsWatcher;
     this.extraReviewers = ImmutableSetMultimap.copyOf(extraReviewers);
-    this.projectControl = projectControl;
+    this.projectState = projectState;
+    this.user = user;
     this.rp = rp;
 
     // Immutable fields derived from constructor arguments.
     repo = rp.getRepository();
-    project = projectControl.getProject();
-    labelTypes = projectControl.getProjectState().getLabelTypes();
+    project = projectState.getProject();
+    labelTypes = projectState.getLabelTypes();
     permissions = permissionBackend.user(user).project(project.getNameKey());
     receiveId = RequestId.forProject(project.getNameKey());
     rejectCommits = BanCommit.loadRejectCommitsMap(rp.getRepository(), rp.getRevWalk());
@@ -475,8 +477,7 @@
     newChanges = Collections.emptyList();
 
     // Other settings populated during processing.
-    newChangeForAllNotInTarget =
-        projectControl.getProjectState().isCreateNewChangeForAllNotInTarget();
+    newChangeForAllNotInTarget = projectState.isCreateNewChangeForAllNotInTarget();
 
     // Handles for outputting back over the wire to the end user.
     messageSender = new ReceivePackMessageSender();
@@ -484,7 +485,7 @@
 
   void init() {
     for (ReceivePackInitializer i : initializers) {
-      i.init(projectControl.getProject().getNameKey(), rp);
+      i.init(projectState.getNameKey(), rp);
     }
   }
 
@@ -819,8 +820,7 @@
         continue;
       }
 
-      if (projectControl.getProjectState().isAllUsers()
-          && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
+      if (projectState.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;
@@ -871,8 +871,14 @@
 
       if (isConfig(cmd)) {
         logDebug("Processing {} command", cmd.getRefName());
-        if (!projectControl.isOwner()) {
-          reject(cmd, "not project owner");
+        try {
+          permissions.check(ProjectPermission.WRITE_CONFIG);
+        } catch (AuthException e) {
+          reject(
+              cmd,
+              String.format(
+                  "must be either project owner or have %s permission",
+                  ProjectPermission.WRITE_CONFIG.describeForException()));
           continue;
         }
 
@@ -927,16 +933,14 @@
                 ProjectConfigEntry configEntry = e.getProvider().get();
                 String value = pluginCfg.getString(e.getExportName());
                 String oldValue =
-                    projectControl
-                        .getProjectState()
+                    projectState
                         .getConfig()
                         .getPluginConfig(e.getPluginName())
                         .getString(e.getExportName());
                 if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
                   oldValue =
                       Arrays.stream(
-                              projectControl
-                                  .getProjectState()
+                              projectState
                                   .getConfig()
                                   .getPluginConfig(e.getPluginName())
                                   .getStringList(e.getExportName()))
@@ -944,7 +948,7 @@
                 }
 
                 if ((value == null ? oldValue != null : !value.equals(oldValue))
-                    && !configEntry.isEditable(projectControl.getProjectState())) {
+                    && !configEntry.isEditable(projectState)) {
                   reject(
                       cmd,
                       String.format(
@@ -1451,7 +1455,7 @@
       reject(cmd, "see help");
       return;
     }
-    if (projectControl.getProjectState().isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
+    if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
       logDebug("Handling {}", RefNames.REFS_USERS_SELF);
       ref = RefNames.refsUsers(user.getAccountId());
     }
@@ -1470,7 +1474,7 @@
 
     magicBranch.dest = new Branch.NameKey(project.getNameKey(), ref);
     magicBranch.perm = permissions.ref(ref);
-    if (!projectControl.getProject().getState().permitsWrite()) {
+    if (!projectState.getProject().getState().permitsWrite()) {
       reject(cmd, "project state does not permit write");
       return;
     }
@@ -2492,7 +2496,7 @@
       replaceOp =
           replaceOpFactory
               .create(
-                  projectControl,
+                  projectState,
                   notes.getChange().getDest(),
                   checkMergedInto,
                   priorPatchSet,
@@ -2572,7 +2576,11 @@
       }
       if (isConfig(cmd)) {
         logDebug("Reloading project in cache");
-        projectCache.evict(project);
+        try {
+          projectCache.evict(project);
+        } catch (IOException e) {
+          log.warn("Cannot evict from project cache, name key: " + project.getName(), e);
+        }
         ProjectState ps = projectCache.get(project.getNameKey());
         try {
           logDebug("Updating project description");
@@ -2790,8 +2798,7 @@
     // TODO(dborowitz): Combine this BatchUpdate with the main one in
     // insertChangesAndPatchSets.
     try (BatchUpdate bu =
-            batchUpdateFactory.create(
-                db, projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
+            batchUpdateFactory.create(db, projectState.getNameKey(), user, TimeUtil.nowTs());
         ObjectInserter ins = repo.newObjectInserter();
         ObjectReader reader = ins.newReader();
         RevWalk rw = new RevWalk(reader)) {
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
index 3645392..a90668a 100644
--- 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
@@ -22,6 +22,7 @@
 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.git.HookUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
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
index 4455aed..9220bc9 100644
--- 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
@@ -54,7 +54,7 @@
 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.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -84,7 +84,7 @@
 public class ReplaceOp implements BatchUpdateOp {
   public interface Factory {
     ReplaceOp create(
-        ProjectControl projectControl,
+        ProjectState projectState,
         Branch.NameKey dest,
         boolean checkMergedInto,
         @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -117,7 +117,7 @@
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
 
-  private final ProjectControl projectControl;
+  private final ProjectState projectState;
   private final Branch.NameKey dest;
   private final boolean checkMergedInto;
   private final PatchSet.Id priorPatchSetId;
@@ -159,7 +159,7 @@
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       ProjectCache projectCache,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
-      @Assisted ProjectControl projectControl,
+      @Assisted ProjectState projectState,
       @Assisted Branch.NameKey dest,
       @Assisted boolean checkMergedInto,
       @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -186,7 +186,7 @@
     this.projectCache = projectCache;
     this.sendEmailExecutor = sendEmailExecutor;
 
-    this.projectControl = projectControl;
+    this.projectState = projectState;
     this.dest = dest;
     this.checkMergedInto = checkMergedInto;
     this.priorPatchSetId = priorPatchSetId;
@@ -205,7 +205,7 @@
     ctx.getRevWalk().parseBody(commit);
     changeKind =
         changeKindCache.getChangeKind(
-            projectControl.getProject().getNameKey(),
+            projectState.getNameKey(),
             ctx.getRevWalk(),
             ctx.getRepoView().getConfig(),
             priorCommitId,
@@ -299,7 +299,7 @@
         approvalsUtil.addApprovalsForNewPatchSet(
             ctx.getDb(),
             update,
-            projectControl.getProjectState().getLabelTypes(),
+            projectState.getLabelTypes(),
             newPatchSet,
             ctx.getUser(),
             approvals);
@@ -314,7 +314,7 @@
     approvalsUtil.addReviewers(
         ctx.getDb(),
         update,
-        projectControl.getProjectState().getLabelTypes(),
+        projectState.getLabelTypes(),
         change,
         newPatchSet,
         info,
@@ -406,7 +406,7 @@
           continue;
         }
 
-        LabelType lt = projectControl.getProjectState().getLabelTypes().byLabel(a.getLabelId());
+        LabelType lt = projectState.getLabelTypes().byLabel(a.getLabelId());
         if (lt != null) {
           current.put(lt.getName(), a);
         }
@@ -496,8 +496,7 @@
     public void run() {
       try {
         ReplacePatchSetSender cm =
-            replacePatchSetFactory.create(
-                projectControl.getProject().getNameKey(), notes.getChangeId());
+            replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
         cm.setFrom(ctx.getAccount().getId());
         cm.setPatchSet(newPatchSet, info);
         cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
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 2a22c1c..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
@@ -50,7 +50,6 @@
 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;
@@ -107,7 +106,6 @@
 
     final AccountCache accountCache;
     final ApprovalsUtil approvalsUtil;
-    final ChangeControl.GenericFactory changeControlFactory;
     final ChangeMerged changeMerged;
     final ChangeMessagesUtil cmUtil;
     final EmailMerge.Factory mergedSenderFactory;
@@ -146,7 +144,6 @@
     Arguments(
         AccountCache accountCache,
         ApprovalsUtil approvalsUtil,
-        ChangeControl.GenericFactory changeControlFactory,
         ChangeMerged changeMerged,
         ChangeMessagesUtil cmUtil,
         EmailMerge.Factory mergedSenderFactory,
@@ -178,7 +175,6 @@
         @Assisted boolean dryrun) {
       this.accountCache = accountCache;
       this.approvalsUtil = approvalsUtil;
-      this.changeControlFactory = changeControlFactory;
       this.changeMerged = changeMerged;
       this.mergedSenderFactory = mergedSenderFactory;
       this.repoManager = repoManager;
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 152d398..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
@@ -45,7 +45,6 @@
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.SubmoduleException;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -330,7 +329,7 @@
   }
 
   private void setApproval(ChangeContext ctx, IdentifiedUser user)
-      throws OrmException, IOException, PermissionBackendException {
+      throws OrmException, IOException {
     Change.Id id = ctx.getChange().getId();
     List<SubmitRecord> records = args.commitStatus.getSubmitRecords(id);
     PatchSet.Id oldPsId = toMerge.getPatchsetId();
@@ -352,7 +351,7 @@
   }
 
   private LabelNormalizer.Result approve(ChangeContext ctx, ChangeUpdate update)
-      throws OrmException, IOException, PermissionBackendException {
+      throws OrmException, IOException {
     PatchSet.Id psId = update.getPatchSetId();
     Map<PatchSetApproval.Key, PatchSetApproval> byKey = new HashMap<>();
     for (PatchSetApproval psa :
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 b6bcb3b..24b3f36 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
@@ -204,8 +204,7 @@
     return result;
   }
 
-  static class PutMember implements RestModifyView<GroupResource, PutMember.Input> {
-    static class Input {}
+  static class PutMember implements RestModifyView<GroupResource, Input> {
 
     private final AddMembers put;
     private final String id;
@@ -216,7 +215,7 @@
     }
 
     @Override
-    public AccountInfo apply(GroupResource resource, PutMember.Input input)
+    public AccountInfo apply(GroupResource resource, Input input)
         throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
             IOException, ConfigInvalidException {
       AddMembers.Input in = new AddMembers.Input();
@@ -234,7 +233,7 @@
   }
 
   @Singleton
-  static class UpdateMember implements RestModifyView<MemberResource, PutMember.Input> {
+  static class UpdateMember implements RestModifyView<MemberResource, Input> {
     private final GetMember get;
 
     @Inject
@@ -243,7 +242,7 @@
     }
 
     @Override
-    public AccountInfo apply(MemberResource resource, PutMember.Input input) throws OrmException {
+    public AccountInfo apply(MemberResource resource, Input input) throws OrmException {
       // Do nothing, the user is already a member.
       return get.apply(resource);
     }
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
index 2ce168f..f60a8ce 100644
--- 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
@@ -114,8 +114,7 @@
     return result;
   }
 
-  static class PutSubgroup implements RestModifyView<GroupResource, PutSubgroup.Input> {
-    static class Input {}
+  static class PutSubgroup implements RestModifyView<GroupResource, Input> {
 
     private final AddSubgroups addSubgroups;
     private final String id;
@@ -144,7 +143,7 @@
   }
 
   @Singleton
-  static class UpdateSubgroup implements RestModifyView<SubgroupResource, PutSubgroup.Input> {
+  static class UpdateSubgroup implements RestModifyView<SubgroupResource, Input> {
     private final Provider<GetSubgroup> get;
 
     @Inject
@@ -153,7 +152,7 @@
     }
 
     @Override
-    public GroupInfo apply(SubgroupResource resource, PutSubgroup.Input input) throws OrmException {
+    public GroupInfo apply(SubgroupResource resource, 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/DeleteMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
index 1069e1c..64de014 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
@@ -82,8 +82,7 @@
   }
 
   @Singleton
-  static class DeleteMember implements RestModifyView<MemberResource, DeleteMember.Input> {
-    static class Input {}
+  static class DeleteMember implements RestModifyView<MemberResource, Input> {
 
     private final Provider<DeleteMembers> delete;
 
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
index 14df51b..43c7d59 100644
--- 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
@@ -82,8 +82,7 @@
   }
 
   @Singleton
-  static class DeleteSubgroup implements RestModifyView<SubgroupResource, DeleteSubgroup.Input> {
-    static class Input {}
+  static class DeleteSubgroup implements RestModifyView<SubgroupResource, Input> {
 
     private final Provider<DeleteSubgroups> delete;
 
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 b61f954..b2845fa 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,13 +14,13 @@
 
 package com.google.gerrit.server.group;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.Index.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -28,7 +28,6 @@
 
 @Singleton
 public class Index implements RestModifyView<GroupResource, Input> {
-  public static class Input {}
 
   private final GroupCache groupCache;
 
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 910468f..6fbfd67 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
@@ -28,6 +28,7 @@
 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.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
@@ -41,7 +42,6 @@
 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.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -56,6 +56,7 @@
 import java.util.Set;
 import java.util.SortedMap;
 import java.util.TreeMap;
+import java.util.function.Predicate;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
 import org.kohsuke.args4j.Option;
@@ -67,7 +68,7 @@
 
   protected final GroupCache groupCache;
 
-  private final List<ProjectControl> projects = new ArrayList<>();
+  private final List<ProjectState> projects = new ArrayList<>();
   private final Set<AccountGroup.UUID> groupsToInspect = new HashSet<>();
   private final GroupControl.Factory groupControlFactory;
   private final GroupControl.GenericFactory genericGroupControlFactory;
@@ -77,6 +78,7 @@
   private final GroupJson json;
   private final GroupBackend groupBackend;
   private final Groups groups;
+  private final GroupsCollection groupsCollection;
   private final Provider<ReviewDb> db;
 
   private EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
@@ -88,13 +90,14 @@
   private String matchSubstring;
   private String matchRegex;
   private String suggest;
+  private String ownedBy;
 
   @Option(
     name = "--project",
     aliases = {"-p"},
     usage = "projects for which the groups should be listed"
   )
-  public void addProject(ProjectControl project) {
+  public void addProject(ProjectState project) {
     projects.add(project);
   }
 
@@ -209,6 +212,11 @@
     options.addAll(ListGroupsOption.fromBits(Integer.parseInt(hex, 16)));
   }
 
+  @Option(name = "--owned-by", usage = "list groups owned by the given group uuid")
+  public void setOwnedBy(String ownedBy) {
+    this.ownedBy = ownedBy;
+  }
+
   @Inject
   protected ListGroups(
       final GroupCache groupCache,
@@ -217,6 +225,7 @@
       final Provider<IdentifiedUser> identifiedUser,
       final IdentifiedUser.GenericFactory userFactory,
       final GetGroups accountGetGroups,
+      final GroupsCollection groupsCollection,
       GroupJson json,
       GroupBackend groupBackend,
       Groups groups,
@@ -230,6 +239,7 @@
     this.json = json;
     this.groupBackend = groupBackend;
     this.groups = groups;
+    this.groupsCollection = groupsCollection;
     this.db = db;
   }
 
@@ -241,13 +251,13 @@
     return user;
   }
 
-  public List<ProjectControl> getProjects() {
+  public List<ProjectState> getProjects() {
     return projects;
   }
 
   @Override
   public SortedMap<String, GroupInfo> apply(TopLevelResource resource)
-      throws OrmException, BadRequestException {
+      throws OrmException, RestApiException {
     SortedMap<String, GroupInfo> output = new TreeMap<>();
     for (GroupInfo info : get()) {
       output.put(MoreObjects.firstNonNull(info.name, "Group " + Url.decode(info.id)), info);
@@ -256,7 +266,7 @@
     return output;
   }
 
-  public List<GroupInfo> get() throws OrmException, BadRequestException {
+  public List<GroupInfo> get() throws OrmException, RestApiException {
     if (!Strings.isNullOrEmpty(suggest)) {
       return suggestGroups();
     }
@@ -265,6 +275,10 @@
       throw new BadRequestException("Specify one of m/r");
     }
 
+    if (ownedBy != null) {
+      return getGroupsOwnedBy(ownedBy);
+    }
+
     if (owned) {
       return getGroupsOwnedBy(user != null ? userFactory.create(user) : identifiedUser.get());
     }
@@ -298,7 +312,6 @@
     if (!projects.isEmpty()) {
       return projects
           .stream()
-          .map(ProjectControl::getProjectState)
           .map(ProjectState::getAllGroups)
           .flatMap(Collection::stream)
           .map(GroupReference::getUUID)
@@ -318,9 +331,7 @@
     List<GroupReference> groupRefs =
         Lists.newArrayList(
             Iterables.limit(
-                groupBackend.suggest(
-                    suggest,
-                    projects.stream().findFirst().map(pc -> pc.getProjectState()).orElse(null)),
+                groupBackend.suggest(suggest, projects.stream().findFirst().orElse(null)),
                 limit <= 0 ? 10 : Math.min(limit, 10)));
 
     List<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(groupRefs.size());
@@ -349,6 +360,9 @@
     if (owned) {
       return true;
     }
+    if (ownedBy != null) {
+      return true;
+    }
     if (start != 0) {
       return true;
     }
@@ -364,14 +378,15 @@
     return false;
   }
 
-  private List<GroupInfo> getGroupsOwnedBy(IdentifiedUser user) throws OrmException {
+  private List<GroupInfo> filterGroupsOwnedBy(Predicate<GroupDescription.Internal> filter)
+      throws OrmException {
     Pattern pattern = getRegexPattern();
     Stream<GroupDescription.Internal> foundGroups =
         groups
             .getAll(db.get())
             .map(GroupDescriptions::forAccountGroup)
             .filter(group -> !isNotRelevant(pattern, group))
-            .filter(group -> isOwner(user, group))
+            .filter(filter)
             .sorted(GROUP_COMPARATOR)
             .skip(start);
     if (limit > 0) {
@@ -385,6 +400,15 @@
     return groupInfos;
   }
 
+  private List<GroupInfo> getGroupsOwnedBy(String id) throws OrmException, RestApiException {
+    String uuid = groupsCollection.parse(id).getGroupUUID().get();
+    return filterGroupsOwnedBy(group -> group.getOwnerGroupUUID().get().equals(uuid));
+  }
+
+  private List<GroupInfo> getGroupsOwnedBy(IdentifiedUser user) throws OrmException {
+    return filterGroupsOwnedBy(group -> isOwner(user, group));
+  }
+
   private boolean isOwner(CurrentUser user, GroupDescription.Internal group) {
     try {
       return genericGroupControlFactory.controlFor(user, group.getGroupUUID()).isOwner();
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 3d6feea..757ad31 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
@@ -17,15 +17,14 @@
 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.DescriptionInput;
 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.Response;
 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.group.PutDescription.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -34,11 +33,7 @@
 import java.util.Objects;
 
 @Singleton
-public class PutDescription implements RestModifyView<GroupResource, Input> {
-  public static class Input {
-    @DefaultInput public String description;
-  }
-
+public class PutDescription implements RestModifyView<GroupResource, DescriptionInput> {
   private final Provider<ReviewDb> db;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
 
@@ -50,11 +45,11 @@
   }
 
   @Override
-  public Response<String> apply(GroupResource resource, Input input)
+  public Response<String> apply(GroupResource resource, DescriptionInput input)
       throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
           IOException {
     if (input == null) {
-      input = new Input(); // Delete would set description to null.
+      input = new DescriptionInput(); // Delete would set description to null.
     }
 
     GroupDescription.Internal internalGroup =
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 75a7eb5..3e7fd41 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
@@ -18,16 +18,15 @@
 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.common.NameInput;
 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.server.ReviewDb;
-import com.google.gerrit.server.group.PutName.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -35,11 +34,7 @@
 import java.io.IOException;
 
 @Singleton
-public class PutName implements RestModifyView<GroupResource, Input> {
-  public static class Input {
-    @DefaultInput public String name;
-  }
-
+public class PutName implements RestModifyView<GroupResource, NameInput> {
   private final Provider<ReviewDb> db;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
 
@@ -50,7 +45,7 @@
   }
 
   @Override
-  public String apply(GroupResource rsrc, Input input)
+  public String apply(GroupResource rsrc, NameInput input)
       throws MethodNotAllowedException, AuthException, BadRequestException,
           ResourceConflictException, ResourceNotFoundException, OrmException, IOException {
     GroupDescription.Internal internalGroup =
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 20e1dbe..6efd6b1 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
@@ -17,17 +17,16 @@
 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.api.groups.OwnerInput;
 import com.google.gerrit.extensions.common.GroupInfo;
 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.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.group.PutOwner.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -35,11 +34,7 @@
 import java.io.IOException;
 
 @Singleton
-public class PutOwner implements RestModifyView<GroupResource, Input> {
-  public static class Input {
-    @DefaultInput public String owner;
-  }
-
+public class PutOwner implements RestModifyView<GroupResource, OwnerInput> {
   private final GroupsCollection groupsCollection;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final Provider<ReviewDb> db;
@@ -58,7 +53,7 @@
   }
 
   @Override
-  public GroupInfo apply(GroupResource resource, Input input)
+  public GroupInfo apply(GroupResource resource, OwnerInput input)
       throws ResourceNotFoundException, MethodNotAllowedException, AuthException,
           BadRequestException, UnprocessableEntityException, OrmException, IOException {
     GroupDescription.Internal internalGroup =
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 c5fd2cb..0a89b2a 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
@@ -24,8 +24,6 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 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.group.GroupQueryBuilder;
 import com.google.gerrit.server.query.group.GroupQueryProcessor;
 import com.google.gwtorm.server.OrmException;
@@ -36,7 +34,6 @@
 import org.kohsuke.args4j.Option;
 
 public class QueryGroups implements RestReadView<TopLevelResource> {
-  private final GroupIndexCollection indexes;
   private final GroupQueryBuilder queryBuilder;
   private final GroupQueryProcessor queryProcessor;
   private final GroupJson json;
@@ -90,11 +87,7 @@
 
   @Inject
   protected QueryGroups(
-      GroupIndexCollection indexes,
-      GroupQueryBuilder queryBuilder,
-      GroupQueryProcessor queryProcessor,
-      GroupJson json) {
-    this.indexes = indexes;
+      GroupQueryBuilder queryBuilder, GroupQueryProcessor queryProcessor, GroupJson json) {
     this.queryBuilder = queryBuilder;
     this.queryProcessor = queryProcessor;
     this.json = json;
@@ -111,11 +104,6 @@
       throw new MethodNotAllowedException("query disabled");
     }
 
-    GroupIndex searchIndex = indexes.getSearchIndex();
-    if (searchIndex == null) {
-      throw new MethodNotAllowedException("no group index");
-    }
-
     if (start != 0) {
       queryProcessor.setStart(start);
     }
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 481726b..85d6a7c 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
@@ -23,6 +23,8 @@
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.DummyChangeIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.project.ProjectIndex;
+import com.google.gerrit.server.project.ProjectData;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
 
@@ -48,6 +50,13 @@
     }
   }
 
+  private static class DummyProjectIndexFactory implements ProjectIndex.Factory {
+    @Override
+    public ProjectIndex create(Schema<ProjectData> schema) {
+      throw new UnsupportedOperationException();
+    }
+  }
+
   @Override
   protected void configure() {
     install(new IndexModule(1));
@@ -56,5 +65,6 @@
     bind(AccountIndex.Factory.class).toInstance(new DummyAccountIndexFactory());
     bind(ChangeIndex.Factory.class).toInstance(new DummyChangeIndexFactory());
     bind(GroupIndex.Factory.class).toInstance(new DummyGroupIndexFactory());
+    bind(ProjectIndex.Factory.class).toInstance(new DummyProjectIndexFactory());
   }
 }
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 6854a87..8c9a964 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
@@ -45,6 +45,12 @@
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.index.group.GroupIndexerImpl;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.server.index.project.ProjectIndexCollection;
+import com.google.gerrit.server.index.project.ProjectIndexDefinition;
+import com.google.gerrit.server.index.project.ProjectIndexRewriter;
+import com.google.gerrit.server.index.project.ProjectIndexer;
+import com.google.gerrit.server.index.project.ProjectIndexerImpl;
+import com.google.gerrit.server.index.project.ProjectSchemaDefinitions;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Provides;
@@ -70,7 +76,8 @@
       ImmutableList.<SchemaDefinitions<?>>of(
           AccountSchemaDefinitions.INSTANCE,
           ChangeSchemaDefinitions.INSTANCE,
-          GroupSchemaDefinitions.INSTANCE);
+          GroupSchemaDefinitions.INSTANCE,
+          ProjectSchemaDefinitions.INSTANCE);
 
   /** Type of secondary index. */
   public static IndexType getIndexType(Injector injector) {
@@ -112,14 +119,22 @@
     listener().to(GroupIndexCollection.class);
     factory(GroupIndexerImpl.Factory.class);
 
+    bind(ProjectIndexRewriter.class);
+    bind(ProjectIndexCollection.class);
+    listener().to(ProjectIndexCollection.class);
+    factory(ProjectIndexerImpl.Factory.class);
+
     DynamicSet.setOf(binder(), OnlineUpgradeListener.class);
   }
 
   @Provides
   Collection<IndexDefinition<?, ?, ?>> getIndexDefinitions(
-      AccountIndexDefinition accounts, ChangeIndexDefinition changes, GroupIndexDefinition groups) {
+      AccountIndexDefinition accounts,
+      ChangeIndexDefinition changes,
+      GroupIndexDefinition groups,
+      ProjectIndexDefinition projects) {
     Collection<IndexDefinition<?, ?, ?>> result =
-        ImmutableList.<IndexDefinition<?, ?, ?>>of(accounts, groups, changes);
+        ImmutableList.<IndexDefinition<?, ?, ?>>of(accounts, groups, changes, projects);
     Set<String> expected =
         FluentIterable.from(ALL_SCHEMA_DEFS).transform(SchemaDefinitions::getName).toSet();
     Set<String> actual = FluentIterable.from(result).transform(IndexDefinition::getName).toSet();
@@ -156,6 +171,13 @@
 
   @Provides
   @Singleton
+  ProjectIndexer getProjectIndexer(
+      ProjectIndexerImpl.Factory factory, ProjectIndexCollection indexes) {
+    return factory.create(indexes);
+  }
+
+  @Provides
+  @Singleton
   @IndexExecutor(INTERACTIVE)
   ListeningExecutorService getInteractiveIndexExecutor(
       @GerritServerConfig Config config, WorkQueue workQueue) {
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 ea9900b..b37ed61 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
@@ -26,6 +26,7 @@
 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.index.project.ProjectField;
 import com.google.gerrit.server.query.change.SingleGroupUser;
 import java.io.IOException;
 import java.util.Set;
@@ -94,6 +95,13 @@
     return user.toString();
   }
 
+  public static Set<String> projectFields(QueryOptions opts) {
+    Set<String> fs = opts.fields();
+    return fs.contains(ProjectField.NAME.getName())
+        ? fs
+        : Sets.union(fs, ImmutableSet.of(ProjectField.NAME.getName()));
+  }
+
   private IndexUtils() {
     // hide default constructor
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
new file mode 100644
index 0000000..a53434e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.project;
+
+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.Project;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class AllProjectsIndexer extends SiteIndexer<Project.NameKey, ProjectData, ProjectIndex> {
+
+  private static final Logger log = LoggerFactory.getLogger(AllProjectsIndexer.class);
+
+  private final ListeningExecutorService executor;
+  private final ProjectCache projectCache;
+
+  @Inject
+  AllProjectsIndexer(
+      @IndexExecutor(BATCH) ListeningExecutorService executor, ProjectCache projectCache) {
+    this.executor = executor;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public SiteIndexer.Result indexAll(final ProjectIndex index) {
+    ProgressMonitor progress = new TextProgressMonitor(new PrintWriter(progressOut));
+    progress.start(2);
+    List<Project.NameKey> names = collectProjects(progress);
+    return reindexProjects(index, names, progress);
+  }
+
+  private SiteIndexer.Result reindexProjects(
+      ProjectIndex index, List<Project.NameKey> names, ProgressMonitor progress) {
+    progress.beginTask("Reindexing projects", names.size());
+    List<ListenableFuture<?>> futures = new ArrayList<>(names.size());
+    AtomicBoolean ok = new AtomicBoolean(true);
+    AtomicInteger done = new AtomicInteger();
+    AtomicInteger failed = new AtomicInteger();
+    Stopwatch sw = Stopwatch.createStarted();
+    for (Project.NameKey name : names) {
+      String desc = "project " + name;
+      ListenableFuture<?> future =
+          executor.submit(
+              () -> {
+                try {
+                  projectCache.evict(name);
+                  index.replace(projectCache.get(name).toProjectData());
+                  verboseWriter.println("Reindexed " + desc);
+                  done.incrementAndGet();
+                } catch (Exception e) {
+                  failed.incrementAndGet();
+                  throw e;
+                }
+                return null;
+              });
+      addErrorListener(future, desc, progress, ok);
+      futures.add(future);
+    }
+
+    try {
+      Futures.successfulAsList(futures).get();
+    } catch (ExecutionException | InterruptedException e) {
+      log.error("Error waiting on project futures", e);
+      return new SiteIndexer.Result(sw, false, 0, 0);
+    }
+
+    progress.endTask();
+    return new SiteIndexer.Result(sw, ok.get(), done.get(), failed.get());
+  }
+
+  private List<Project.NameKey> collectProjects(ProgressMonitor progress) {
+    progress.beginTask("Collecting projects", ProgressMonitor.UNKNOWN);
+    List<Project.NameKey> names = new ArrayList<>();
+    for (Project.NameKey nameKey : projectCache.all()) {
+      names.add(nameKey);
+    }
+    progress.endTask();
+    return names;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/IndexedProjectQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/IndexedProjectQuery.java
new file mode 100644
index 0000000..41bff05
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/IndexedProjectQuery.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.index.project;
+
+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.Project;
+import com.google.gerrit.server.project.ProjectData;
+
+public class IndexedProjectQuery extends IndexedQuery<Project.NameKey, ProjectData>
+    implements DataSource<ProjectData> {
+
+  public IndexedProjectQuery(
+      Index<Project.NameKey, ProjectData> index, Predicate<ProjectData> pred, QueryOptions opts)
+      throws QueryParseException {
+    super(index, pred, opts.convertForBackend());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectField.java
new file mode 100644
index 0000000..c4f8e9e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectField.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.project;
+
+import static com.google.gerrit.index.FieldDef.exact;
+import static com.google.gerrit.index.FieldDef.fullText;
+import static com.google.gerrit.index.FieldDef.prefix;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaUtil;
+import com.google.gerrit.server.project.ProjectData;
+
+/** Index schema for projects. */
+public class ProjectField {
+
+  public static final FieldDef<ProjectData, String> NAME =
+      exact("name").stored().build(p -> p.getProject().getName());
+
+  public static final FieldDef<ProjectData, String> DESCRIPTION =
+      fullText("description").build(p -> p.getProject().getDescription());
+
+  public static final FieldDef<ProjectData, String> PARENT_NAME =
+      exact("parent_name").build(p -> p.getProject().getParentName());
+
+  public static final FieldDef<ProjectData, Iterable<String>> NAME_PART =
+      prefix("name_part").buildRepeatable(p -> SchemaUtil.getNameParts(p.getProject().getName()));
+
+  public static final FieldDef<ProjectData, Iterable<String>> ANCESTOR_NAME =
+      exact("ancestor_name")
+          .buildRepeatable(p -> Iterables.transform(p.getAncestors(), n -> n.get()));
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndex.java
new file mode 100644
index 0000000..5fbdf04
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndex.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.server.index.project;
+
+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.Project;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.gerrit.server.query.project.ProjectPredicates;
+
+public interface ProjectIndex extends Index<Project.NameKey, ProjectData> {
+
+  public interface Factory
+      extends IndexDefinition.IndexFactory<Project.NameKey, ProjectData, ProjectIndex> {}
+
+  @Override
+  default Predicate<ProjectData> keyPredicate(Project.NameKey nameKey) {
+    return ProjectPredicates.name(nameKey);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexCollection.java
new file mode 100644
index 0000000..eeebfa1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexCollection.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.project;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ProjectIndexCollection
+    extends IndexCollection<Project.NameKey, ProjectData, ProjectIndex> {
+
+  @VisibleForTesting
+  public ProjectIndexCollection() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java
new file mode 100644
index 0000000..301f209
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.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.server.index.project;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.inject.Inject;
+
+public class ProjectIndexDefinition
+    extends IndexDefinition<Project.NameKey, ProjectData, ProjectIndex> {
+
+  @Inject
+  ProjectIndexDefinition(
+      ProjectIndexCollection indexCollection,
+      ProjectIndex.Factory indexFactory,
+      @Nullable AllProjectsIndexer allProjectsIndexer) {
+    super(ProjectSchemaDefinitions.INSTANCE, indexCollection, indexFactory, allProjectsIndexer);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexRewriter.java
new file mode 100644
index 0000000..41d8820
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexRewriter.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.index.project;
+
+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.project.ProjectData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ProjectIndexRewriter implements IndexRewriter<ProjectData> {
+  private final ProjectIndexCollection indexes;
+
+  @Inject
+  ProjectIndexRewriter(ProjectIndexCollection indexes) {
+    this.indexes = indexes;
+  }
+
+  @Override
+  public Predicate<ProjectData> rewrite(Predicate<ProjectData> in, QueryOptions opts)
+      throws QueryParseException {
+    ProjectIndex index = indexes.getSearchIndex();
+    checkNotNull(index, "no active search index configured for projects");
+    return new IndexedProjectQuery(index, in, opts);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexer.java
new file mode 100644
index 0000000..e8a8183
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexer.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.index.project;
+
+import com.google.gerrit.reviewdb.client.Project;
+import java.io.IOException;
+
+public interface ProjectIndexer {
+
+  /**
+   * Synchronously index a project.
+   *
+   * @param nameKey name key of project to index.
+   */
+  void index(Project.NameKey nameKey) throws IOException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
new file mode 100644
index 0000000..2a51f32
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.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.index.project;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.events.ProjectIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+
+public class ProjectIndexerImpl implements ProjectIndexer {
+  public interface Factory {
+    ProjectIndexerImpl create(ProjectIndexCollection indexes);
+
+    ProjectIndexerImpl create(@Nullable ProjectIndex index);
+  }
+
+  private final ProjectCache projectCache;
+  private final DynamicSet<ProjectIndexedListener> indexedListener;
+  private final ProjectIndexCollection indexes;
+  private final ProjectIndex index;
+
+  @AssistedInject
+  ProjectIndexerImpl(
+      ProjectCache projectCache,
+      DynamicSet<ProjectIndexedListener> indexedListener,
+      @Assisted ProjectIndexCollection indexes) {
+    this.projectCache = projectCache;
+    this.indexedListener = indexedListener;
+    this.indexes = indexes;
+    this.index = null;
+  }
+
+  @AssistedInject
+  ProjectIndexerImpl(
+      ProjectCache projectCache,
+      DynamicSet<ProjectIndexedListener> indexedListener,
+      @Assisted ProjectIndex index) {
+    this.projectCache = projectCache;
+    this.indexedListener = indexedListener;
+    this.indexes = null;
+    this.index = index;
+  }
+
+  @Override
+  public void index(Project.NameKey nameKey) throws IOException {
+    for (Index<?, ProjectData> i : getWriteIndexes()) {
+      i.replace(projectCache.get(nameKey).toProjectData());
+    }
+    fireProjectIndexedEvent(nameKey.get());
+  }
+
+  private void fireProjectIndexedEvent(String name) {
+    for (ProjectIndexedListener listener : indexedListener) {
+      listener.onProjectIndexed(name);
+    }
+  }
+
+  private Collection<ProjectIndex> getWriteIndexes() {
+    if (indexes != null) {
+      return indexes.getWriteIndexes();
+    }
+
+    return index != null ? Collections.singleton(index) : ImmutableSet.of();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectSchemaDefinitions.java
new file mode 100644
index 0000000..ccece02
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectSchemaDefinitions.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.index.project;
+
+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.project.ProjectData;
+
+public class ProjectSchemaDefinitions extends SchemaDefinitions<ProjectData> {
+
+  static final Schema<ProjectData> V1 =
+      schema(
+          ProjectField.NAME,
+          ProjectField.DESCRIPTION,
+          ProjectField.PARENT_NAME,
+          ProjectField.NAME_PART,
+          ProjectField.ANCESTOR_NAME);
+
+  public static final ProjectSchemaDefinitions INSTANCE = new ProjectSchemaDefinitions();
+
+  private ProjectSchemaDefinitions() {
+    super("projects", ProjectData.class);
+  }
+}
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 68b3c23..0d20464 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
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.mail.Address;
-import org.joda.time.DateTime;
+import java.time.Instant;
 
 /**
  * A simplified representation of an RFC 2045-2047 mime email message used for representing received
@@ -40,7 +40,7 @@
 
   public abstract ImmutableList<Address> cc();
   // Metadata
-  public abstract DateTime dateReceived();
+  public abstract Instant dateReceived();
 
   public abstract ImmutableList<String> additionalHeaders();
   // Content
@@ -84,7 +84,7 @@
       return this;
     }
 
-    public abstract Builder dateReceived(DateTime val);
+    public abstract Builder dateReceived(Instant instant);
 
     public abstract ImmutableList.Builder<String> additionalHeadersBuilder();
 
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 d2f91ed..57fe21f 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
@@ -33,7 +33,6 @@
 import org.apache.james.mime4j.dom.TextBody;
 import org.apache.james.mime4j.dom.address.Mailbox;
 import org.apache.james.mime4j.message.DefaultMessageBuilder;
-import org.joda.time.DateTime;
 
 /** Parses raw email content received through POP3 or IMAP into an internal {@link MailMessage}. */
 public class RawMailParser {
@@ -66,7 +65,9 @@
     if (mimeMessage.getSubject() != null) {
       messageBuilder.subject(mimeMessage.getSubject());
     }
-    messageBuilder.dateReceived(new DateTime(mimeMessage.getDate()));
+    if (mimeMessage.getDate() != null) {
+      messageBuilder.dateReceived(mimeMessage.getDate().toInstant());
+    }
 
     // Add From, To and Cc
     if (mimeMessage.getFrom() != null && mimeMessage.getFrom().size() > 0) {
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 53e7d22..e862c38 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
@@ -23,7 +23,6 @@
 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.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
@@ -37,6 +36,7 @@
 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;
@@ -115,11 +115,6 @@
     patchSetInfo = psi;
   }
 
-  @Deprecated
-  public void setChangeMessage(ChangeMessage cm) {
-    setChangeMessage(cm.getMessage(), cm.getWrittenOn());
-  }
-
   public void setChangeMessage(String cm, Timestamp t) {
     changeMessage = cm;
     timestamp = t;
@@ -221,7 +216,7 @@
     }
   }
 
-  private void setChangeSubjectHeader() throws EmailException {
+  private void setChangeSubjectHeader() {
     setHeader("Subject", textTemplate("ChangeSubject"));
   }
 
@@ -236,8 +231,14 @@
     return null;
   }
 
-  public String getChangeMessageThreadId() throws EmailException {
-    return velocify("<gerrit.${change.createdOn.time}.$change.key.get()@$email.gerritHost>");
+  public String getChangeMessageThreadId() {
+    return "<gerrit."
+        + change.getCreatedOn().getTime()
+        + "."
+        + change.getKey().get()
+        + "@"
+        + this.getGerritHost()
+        + ">";
   }
 
   /** Format the sender's "cover letter", {@link #getCoverLetter()}. */
@@ -445,17 +446,6 @@
   }
 
   @Override
-  protected void setupVelocityContext() {
-    super.setupVelocityContext();
-    velocityContext.put("change", change);
-    velocityContext.put("changeId", change.getKey());
-    velocityContext.put("coverLetter", getCoverLetter());
-    velocityContext.put("fromName", getNameFor(fromId));
-    velocityContext.put("patchSet", patchSet);
-    velocityContext.put("patchSetInfo", patchSetInfo);
-  }
-
-  @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
 
@@ -493,7 +483,10 @@
     patchSetData.put("refName", patchSet.getRefName());
     soyContext.put("patchSet", patchSetData);
 
-    // TODO(wyatta): patchSetInfo
+    Map<String, Object> patchSetInfoData = new HashMap<>();
+    patchSetInfoData.put("authorName", patchSetInfo.getAuthor().getName());
+    patchSetInfoData.put("authorEmail", patchSetInfo.getAuthor().getEmail());
+    soyContext.put("patchSetInfo", patchSetInfoData);
 
     footers.add("Gerrit-MessageType: " + messageClass);
     footers.add("Gerrit-Change-Id: " + change.getKey().get());
@@ -539,6 +532,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 "";
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 5b7d3b7..8055273 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;
@@ -189,38 +190,6 @@
     }
   }
 
-  /** No longer used outside Velocity. Remove this method when VTL support is removed. */
-  @Deprecated
-  public boolean hasInlineComments() {
-    return !inlineComments.isEmpty();
-  }
-
-  /** No longer used outside Velocity. Remove this method when VTL support is removed. */
-  @Deprecated
-  public String getInlineComments() {
-    return getInlineComments(1);
-  }
-
-  /** No longer used outside Velocity. Remove this method when VTL support is removed. */
-  @Deprecated
-  public String getInlineComments(int lines) {
-    try (Repository repo = getRepository()) {
-      StringBuilder cmts = new StringBuilder();
-      for (FileCommentGroup group : getGroupedInlineComments(repo)) {
-        String link = group.getLink();
-        if (link != null) {
-          cmts.append(link).append('\n');
-        }
-        cmts.append(group.getTitle()).append(":\n\n");
-        for (Comment c : group.comments) {
-          appendComment(cmts, lines, group.fileData, c);
-        }
-        cmts.append("\n\n");
-      }
-      return cmts.toString();
-    }
-  }
-
   /**
    * @return a list of FileCommentGroup objects representing the inline comments grouped by the
    *     file.
@@ -232,6 +201,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);
       }
@@ -272,39 +243,6 @@
     return groups;
   }
 
-  /** No longer used except for Velocity. Remove this method when VTL support is removed. */
-  @Deprecated
-  private void appendComment(
-      StringBuilder out, int contextLines, PatchFile currentFileData, Comment comment) {
-    if (comment instanceof RobotComment) {
-      RobotComment robotComment = (RobotComment) comment;
-      out.append("Robot Comment from ")
-          .append(robotComment.robotId)
-          .append(" (run ID ")
-          .append(robotComment.robotRunId)
-          .append("):\n");
-    }
-    if (comment.range != null) {
-      appendRangedComment(out, currentFileData, comment);
-    } else {
-      appendLineComment(out, contextLines, currentFileData, comment);
-    }
-  }
-
-  /** No longer used except for Velocity. Remove this method when VTL support is removed. */
-  @Deprecated
-  private void appendRangedComment(StringBuilder out, PatchFile fileData, Comment comment) {
-    String prefix = getCommentLinePrefix(comment);
-    String emptyPrefix = Strings.padStart(": ", prefix.length(), ' ');
-    boolean firstLine = true;
-    for (String line : getLinesByRange(comment.range, fileData, comment.side)) {
-      out.append(firstLine ? prefix : emptyPrefix).append(line).append('\n');
-      firstLine = false;
-    }
-    appendQuotedParent(out, comment);
-    out.append(comment.message.trim()).append('\n');
-  }
-
   private String getCommentLinePrefix(Comment comment) {
     int lineNbr = comment.range == null ? comment.lineNbr : comment.range.startLine;
     StringBuilder sb = new StringBuilder();
@@ -336,56 +274,6 @@
     return lines;
   }
 
-  /** No longer used except for Velocity. Remove this method when VTL support is removed. */
-  @Deprecated
-  private void appendLineComment(
-      StringBuilder out, int contextLines, PatchFile currentFileData, Comment comment) {
-    short side = comment.side;
-    int lineNbr = comment.lineNbr;
-
-    // Initialize maxLines to the known line number.
-    int maxLines = lineNbr;
-
-    try {
-      maxLines = currentFileData.getLineCount(side);
-    } catch (IOException err) {
-      // The file could not be read, leave the max as is.
-      log.warn(String.format("Failed to read file %s on side %d", comment.key.filename, side), err);
-    } catch (NoSuchEntityException err) {
-      // The file could not be read, leave the max as is.
-      log.warn(String.format("Side %d of file %s didn't exist", side, comment.key.filename), err);
-    }
-
-    int startLine = Math.max(1, lineNbr - contextLines + 1);
-    int stopLine = Math.min(maxLines, lineNbr + contextLines);
-
-    for (int line = startLine; line <= lineNbr; ++line) {
-      appendFileLine(out, currentFileData, side, line);
-    }
-    appendQuotedParent(out, comment);
-    out.append(comment.message.trim()).append('\n');
-
-    for (int line = lineNbr + 1; line < stopLine; ++line) {
-      appendFileLine(out, currentFileData, side, line);
-    }
-  }
-
-  /** No longer used except for Velocity. Remove this method when VTL support is removed. */
-  @Deprecated
-  private void appendFileLine(StringBuilder cmts, PatchFile fileData, short side, int line) {
-    String lineStr = getLine(fileData, side, line);
-    cmts.append("Line ").append(line).append(": ").append(lineStr).append("\n");
-  }
-
-  /** No longer used except for Velocity. Remove this method when VTL support is removed. */
-  @Deprecated
-  private void appendQuotedParent(StringBuilder out, Comment child) {
-    Optional<Comment> parent = getParent(child);
-    if (parent.isPresent()) {
-      out.append("> ").append(getShortenedCommentMessage(parent.get())).append('\n');
-    }
-  }
-
   /**
    * Get the parent comment of a given comment.
    *
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 6d15d6f..8956f10 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
@@ -14,17 +14,20 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.common.collect.Iterables;
 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.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.util.stream.StreamSupport;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -36,11 +39,20 @@
     CreateChangeSender create(Project.NameKey project, Change.Id id);
   }
 
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final PermissionBackend permissionBackend;
+
   @Inject
   public CreateChangeSender(
-      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
+      EmailArguments ea,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      PermissionBackend permissionBackend,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id)
       throws OrmException {
     super(ea, newChangeData(ea, project, id));
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
@@ -48,16 +60,13 @@
     super.init();
 
     try {
-      // Try to mark interested owners with TO and CC or BCC line.
+      // Upgrade watching owners from CC and BCC to TO.
       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)) {
-          add(RecipientType.TO, user);
-        }
-      }
-
+      // TODO(hiesel): Remove special handling for owners
+      StreamSupport.stream(matching.all().accounts.spliterator(), false)
+          .filter(acc -> isOwnerOfProjectOrBranch(acc))
+          .forEach(acc -> add(RecipientType.TO, acc));
       // Add everyone else. Owners added above will not be duplicated.
       add(RecipientType.TO, matching.to);
       add(RecipientType.CC, matching.cc);
@@ -72,11 +81,10 @@
     includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
   }
 
-  private boolean isOwnerOfProjectOrBranch(Account.Id user) {
-    return projectState != null
-        && projectState
-            .controlFor(args.identifiedUserFactory.create(user))
-            .controlForRef(change.getDest())
-            .isOwner();
+  private boolean isOwnerOfProjectOrBranch(Account.Id userId) {
+    return permissionBackend
+        .user(identifiedUserFactory.create(userId))
+        .ref(change.getDest())
+        .testOrFalse(RefPermission.WRITE_CONFIG);
   }
 }
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 869d7d1..411a98c 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
@@ -46,7 +46,6 @@
 import com.google.inject.Provider;
 import com.google.template.soy.tofu.SoyTofu;
 import java.util.List;
-import org.apache.velocity.runtime.RuntimeInstance;
 import org.eclipse.jgit.lib.PersonIdent;
 
 public class EmailArguments {
@@ -75,7 +74,6 @@
   final ChangeQueryBuilder queryBuilder;
   final Provider<ReviewDb> db;
   final ChangeData.Factory changeDataFactory;
-  final RuntimeInstance velocityRuntime;
   final SoyTofu soyTofu;
   final EmailSettings settings;
   final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
@@ -106,7 +104,6 @@
       ChangeQueryBuilder queryBuilder,
       Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
-      RuntimeInstance velocityRuntime,
       @MailTemplates SoyTofu soyTofu,
       EmailSettings settings,
       @SshAdvertisedAddresses List<String> sshAddresses,
@@ -136,7 +133,6 @@
     this.queryBuilder = queryBuilder;
     this.db = db;
     this.changeDataFactory = changeDataFactory;
-    this.velocityRuntime = velocityRuntime;
     this.soyTofu = soyTofu;
     this.settings = settings;
     this.sshAddresses = sshAddresses;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index bceac72..c192dfa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -45,18 +45,16 @@
     setListIdHeader();
   }
 
-  private void setListIdHeader() throws EmailException {
+  private void setListIdHeader() {
     // Set a reasonable list id so that filters can be used to sort messages
-    setVHeader("List-Id", "<$email.listId.replace('@', '.')>");
+    setHeader(
+        "List-Id",
+        "<gerrit-" + branch.getParentKey().get().replace('/', '-') + "." + getGerritHost() + ">");
     if (getSettingsUrl() != null) {
-      setVHeader("List-Unsubscribe", "<$email.settingsUrl>");
+      setHeader("List-Unsubscribe", "<" + getSettingsUrl() + ">");
     }
   }
 
-  public String getListId() throws EmailException {
-    return velocify("gerrit-$projectName.replace('/', '-')@$email.gerritHost");
-  }
-
   /** Include users and groups that want notification of events. */
   protected void includeWatchers(NotifyType type) {
     includeWatchers(type, true);
@@ -103,13 +101,6 @@
   }
 
   @Override
-  protected void setupVelocityContext() {
-    super.setupVelocityContext();
-    velocityContext.put("projectName", branch.getParentKey().get());
-    velocityContext.put("branch", branch);
-  }
-
-  @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
 
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 e569adf..ec67d9d 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
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED;
-import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
@@ -36,12 +35,8 @@
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
 import com.google.template.soy.data.SanitizedContent;
-import java.io.StringReader;
-import java.io.StringWriter;
 import java.net.MalformedURLException;
 import java.net.URL;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Date;
@@ -53,12 +48,6 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.StringJoiner;
-import org.apache.commons.lang.StringUtils;
-import org.apache.velocity.Template;
-import org.apache.velocity.VelocityContext;
-import org.apache.velocity.context.InternalContextAdapterImpl;
-import org.apache.velocity.runtime.RuntimeInstance;
-import org.apache.velocity.runtime.parser.node.SimpleNode;
 import org.eclipse.jgit.util.SystemReader;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -78,7 +67,6 @@
   private StringBuilder textBody;
   private StringBuilder htmlBody;
   private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of();
-  protected VelocityContext velocityContext;
   protected Map<String, Object> soyContext;
   protected Map<String, Object> soyContextEmailData;
   protected List<String> footers;
@@ -237,7 +225,6 @@
    * @throws EmailException if an error occurred.
    */
   protected void init() throws EmailException {
-    setupVelocityContext();
     setupSoyContext();
 
     smtpFromAddress = args.fromAddressGenerator.from(fromId);
@@ -309,11 +296,6 @@
     return args.urlProvider.get();
   }
 
-  /** Set a header in the outgoing message using a template. */
-  protected void setVHeader(String name, String value) throws EmailException {
-    setHeader(name, velocify(value));
-  }
-
   /** Set a header in the outgoing message. */
   protected void setHeader(String name, String value) {
     headers.put(name, new EmailHeader.String(value));
@@ -537,14 +519,6 @@
     return new Address(a.getFullName(), e);
   }
 
-  protected void setupVelocityContext() {
-    velocityContext = new VelocityContext();
-
-    velocityContext.put("email", this);
-    velocityContext.put("messageClass", messageClass);
-    velocityContext.put("StringUtils", StringUtils.class);
-  }
-
   protected void setupSoyContext() {
     soyContext = new HashMap<>();
     footers = new ArrayList<>();
@@ -559,41 +533,6 @@
     soyContext.put("email", soyContextEmailData);
   }
 
-  protected String velocify(String template) throws EmailException {
-    try {
-      RuntimeInstance runtime = args.velocityRuntime;
-      String templateName = "OutgoingEmail";
-      SimpleNode tree = runtime.parse(new StringReader(template), templateName);
-      InternalContextAdapterImpl ica = new InternalContextAdapterImpl(velocityContext);
-      ica.pushCurrentTemplateName(templateName);
-      try {
-        tree.init(ica, runtime);
-        StringWriter w = new StringWriter();
-        tree.render(ica, w);
-        return w.toString();
-      } finally {
-        ica.popCurrentTemplateName();
-      }
-    } catch (Exception e) {
-      throw new EmailException("Cannot format velocity template: " + template, e);
-    }
-  }
-
-  protected String velocifyFile(String name) throws EmailException {
-    try {
-      RuntimeInstance runtime = args.velocityRuntime;
-      if (runtime.getLoaderNameForResource(name) == null) {
-        name = "com/google/gerrit/server/mail/" + name;
-      }
-      Template template = runtime.getTemplate(name, UTF_8.name());
-      StringWriter w = new StringWriter();
-      template.merge(velocityContext, w);
-      return w.toString();
-    } catch (Exception e) {
-      throw new EmailException("Cannot format velocity template " + name, e);
-    }
-  }
-
   private String soyTemplate(String name, SanitizedContent.ContentKind kind) {
     return args.soyTofu
         .newRenderer("com.google.gerrit.server.mail.template." + name)
@@ -602,7 +541,7 @@
         .render();
   }
 
-  protected String soyTextTemplate(String name) {
+  protected String textTemplate(String name) {
     return soyTemplate(name, SanitizedContent.ContentKind.TEXT);
   }
 
@@ -610,19 +549,6 @@
     return soyTemplate(name, SanitizedContent.ContentKind.HTML);
   }
 
-  /**
-   * Evaluate the named template according to the following priority: 1) Velocity file override,
-   * OR... 2) Soy file override, OR... 3) Soy resource.
-   */
-  protected String textTemplate(String name) throws EmailException {
-    String velocityName = name + ".vm";
-    Path filePath = args.site.mail_dir.resolve(velocityName);
-    if (Files.isRegularFile(filePath)) {
-      return velocifyFile(velocityName);
-    }
-    return soyTextTemplate(name);
-  }
-
   public String joinStrings(Iterable<Object> in, String joiner) {
     return joinStrings(in.iterator(), joiner);
   }
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 e1b6e36..a914a73 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
@@ -119,12 +119,25 @@
     static class List {
       protected final Set<Account.Id> accounts = new HashSet<>();
       protected final Set<Address> emails = new HashSet<>();
+
+      private static List union(List... others) {
+        List union = new List();
+        for (List other : others) {
+          union.accounts.addAll(other.accounts);
+          union.emails.addAll(other.emails);
+        }
+        return union;
+      }
     }
 
     protected final List to = new List();
     protected final List cc = new List();
     protected final List bcc = new List();
 
+    List all() {
+      return List.union(to, cc, bcc);
+    }
+
     List list(NotifyConfig.Header header) {
       switch (header) {
         case TO:
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/VelocityRuntimeProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/VelocityRuntimeProvider.java
deleted file mode 100644
index 524bbed..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/VelocityRuntimeProvider.java
+++ /dev/null
@@ -1,118 +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.mail.send;
-
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import java.nio.file.Files;
-import java.util.Properties;
-import org.apache.velocity.runtime.RuntimeConstants;
-import org.apache.velocity.runtime.RuntimeInstance;
-import org.apache.velocity.runtime.RuntimeServices;
-import org.apache.velocity.runtime.log.LogChute;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Configures Velocity template engine for sending email. */
-@Singleton
-public class VelocityRuntimeProvider implements Provider<RuntimeInstance> {
-  private final SitePaths site;
-
-  @Inject
-  VelocityRuntimeProvider(SitePaths site) {
-    this.site = site;
-  }
-
-  @Override
-  public RuntimeInstance get() {
-    String rl = "resource.loader";
-    String pkg = "org.apache.velocity.runtime.resource.loader";
-
-    Properties p = new Properties();
-    p.setProperty(RuntimeConstants.VM_PERM_INLINE_LOCAL, "true");
-    p.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM_CLASS, Slf4jLogChute.class.getName());
-    p.setProperty(RuntimeConstants.RUNTIME_REFERENCES_STRICT, "true");
-    p.setProperty("runtime.log.logsystem.log4j.category", "velocity");
-
-    if (Files.isDirectory(site.mail_dir)) {
-      p.setProperty(rl, "file, class");
-      p.setProperty("file." + rl + ".class", pkg + ".FileResourceLoader");
-      p.setProperty("file." + rl + ".path", site.mail_dir.toAbsolutePath().toString());
-      p.setProperty("class." + rl + ".class", pkg + ".ClasspathResourceLoader");
-    } else {
-      p.setProperty(rl, "class");
-      p.setProperty("class." + rl + ".class", pkg + ".ClasspathResourceLoader");
-    }
-
-    RuntimeInstance ri = new RuntimeInstance();
-    try {
-      ri.init(p);
-    } catch (Exception err) {
-      throw new ProvisionException("Cannot configure Velocity templates", err);
-    }
-    return ri;
-  }
-
-  /** Connects Velocity to sfl4j. */
-  public static class Slf4jLogChute implements LogChute {
-    // Logger should be named 'velocity' for consistency with log4j config
-    private static final Logger log = LoggerFactory.getLogger("velocity");
-
-    @Override
-    public void init(RuntimeServices rs) {}
-
-    @Override
-    public boolean isLevelEnabled(int level) {
-      switch (level) {
-        default:
-        case DEBUG_ID:
-          return log.isDebugEnabled();
-        case INFO_ID:
-          return log.isInfoEnabled();
-        case WARN_ID:
-          return log.isWarnEnabled();
-        case ERROR_ID:
-          return log.isErrorEnabled();
-      }
-    }
-
-    @Override
-    public void log(int level, String message) {
-      log(level, message, null);
-    }
-
-    @Override
-    public void log(int level, String msg, Throwable err) {
-      switch (level) {
-        default:
-        case DEBUG_ID:
-          log.debug(msg, err);
-          break;
-        case INFO_ID:
-          log.info(msg, err);
-          break;
-        case WARN_ID:
-          log.warn(msg, err);
-          break;
-        case ERROR_ID:
-          log.error(msg, err);
-          break;
-      }
-    }
-  }
-}
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 7777400..8900a15 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
@@ -103,7 +103,7 @@
     try {
       PatchList pl = fileCache.get(key, fileLoaderFactory.create(key, project));
       if (pl instanceof LargeObjectTombstone) {
-        throw new PatchListNotAvailableException(
+        throw new PatchListObjectTooLargeException(
             "Error computing " + key + ". Previous attempt failed with LargeObjectException");
       }
       if (key.getAlgorithm() == PatchListKey.Algorithm.OPTIMIZED_DIFF) {
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/PatchScriptFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 384d4fd..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
@@ -37,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;
@@ -90,7 +92,7 @@
   private final DiffPreferencesInfo diffPrefs;
   private final ChangeEditUtil editReader;
   private final Provider<CurrentUser> userProvider;
-  private final ChangeControl.GenericFactory changeControlFactory;
+  private final PermissionBackend permissionBackend;
   private Optional<ChangeEdit> edit;
 
   private final Change.Id changeId;
@@ -113,7 +115,7 @@
       CommentsUtil commentsUtil,
       ChangeEditUtil editReader,
       Provider<CurrentUser> userProvider,
-      ChangeControl.GenericFactory changeControlFactory,
+      PermissionBackend permissionBackend,
       @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted("patchSetA") @Nullable PatchSet.Id patchSetA,
@@ -128,7 +130,7 @@
     this.commentsUtil = commentsUtil;
     this.editReader = editReader;
     this.userProvider = userProvider;
-    this.changeControlFactory = changeControlFactory;
+    this.permissionBackend = permissionBackend;
 
     this.fileName = fileName;
     this.psa = patchSetA;
@@ -149,7 +151,7 @@
       CommentsUtil commentsUtil,
       ChangeEditUtil editReader,
       Provider<CurrentUser> userProvider,
-      ChangeControl.GenericFactory changeControlFactory,
+      PermissionBackend permissionBackend,
       @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted int parentNum,
@@ -164,7 +166,7 @@
     this.commentsUtil = commentsUtil;
     this.editReader = editReader;
     this.userProvider = userProvider;
-    this.changeControlFactory = changeControlFactory;
+    this.permissionBackend = permissionBackend;
 
     this.fileName = fileName;
     this.psa = null;
@@ -187,7 +189,7 @@
   @Override
   public PatchScript call()
       throws OrmException, LargeObjectException, AuthException, InvalidChangeOperationException,
-          IOException {
+          IOException, PermissionBackendException {
     if (parentNum < 0) {
       validatePatchSetId(psa);
     }
@@ -195,10 +197,16 @@
 
     PatchSet psEntityA = psa != null ? psUtil.get(db, notes, psa) : null;
     PatchSet psEntityB = psb.get() == 0 ? new PatchSet(psb) : psUtil.get(db, notes, psb);
-
-    ChangeControl ctl = changeControlFactory.controlFor(notes, userProvider.get());
-    if ((psEntityA != null && !ctl.isVisible(db)) || (psEntityB != null && !ctl.isVisible(db))) {
-      throw new NoSuchChangeException(changeId);
+    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(notes.getProjectName())) {
@@ -212,8 +220,7 @@
         final PatchScriptBuilder b = newBuilder(list, git);
         final PatchListEntry content = list.get(fileName);
 
-        loadCommentsAndHistory(
-            ctl, content.getChangeType(), content.getOldName(), content.getNewName());
+        loadCommentsAndHistory(content.getChangeType(), content.getOldName(), content.getNewName());
 
         return b.toPatchScript(content, comments, history);
       } catch (PatchListNotAvailableException e) {
@@ -285,8 +292,7 @@
     }
   }
 
-  private void loadCommentsAndHistory(
-      ChangeControl ctl, ChangeType changeType, String oldName, String newName)
+  private void loadCommentsAndHistory(ChangeType changeType, String oldName, String newName)
       throws OrmException {
     Map<Patch.Key, Patch> byKey = new HashMap<>();
 
@@ -298,9 +304,6 @@
       //
       history = new ArrayList<>();
       for (PatchSet ps : psUtil.byChange(db, notes)) {
-        if (!ctl.isVisible(db)) {
-          continue;
-        }
         String name = fileName;
         if (psa != null) {
           switch (changeType) {
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
index 6db9357..c87e8e4 100644
--- 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
@@ -429,65 +429,5 @@
           .map((v) -> new LabelPermission.WithValue(label, v))
           .collect(toSet());
     }
-
-    /**
-     * Squash a label value to the nearest allowed value.
-     *
-     * <p>For multi-valued labels like Code-Review with values -2..+2 a user may try to use +2, but
-     * only have permission for the -1..+1 range. The caller should have already tried:
-     *
-     * <pre>
-     * check(new LabelPermission.WithValue("Code-Review", 2));
-     * </pre>
-     *
-     * and caught {@link AuthException}. {@code squashThenCheck} will use {@link #test(LabelType)}
-     * to determine potential values of Code-Review the user can use, and select the nearest value
-     * along the same sign, e.g. -1 for -2 and +1 for +2.
-     *
-     * @param label definition of the label to test values of.
-     * @param val previously denied value the user attempted.
-     * @return nearest allowed value, or {@code 0} if no value was allowed.
-     * @throws PermissionBackendException backend cannot run test or check.
-     */
-    public short squashThenCheck(LabelType label, short val) throws PermissionBackendException {
-      short s = squashByTest(label, val);
-      if (s == 0 || s == val) {
-        return 0;
-      }
-      try {
-        check(new LabelPermission.WithValue(label, s));
-        return s;
-      } catch (AuthException e) {
-        return 0;
-      }
-    }
-
-    /**
-     * Squash a label value to the nearest allowed value using only test methods.
-     *
-     * <p>Tests all possible values and selects the closet available to {@code val} while matching
-     * the sign of {@code val}. Unlike {@code #squashThenCheck(LabelType, short)} this method only
-     * uses {@code test} methods and should not be used in contexts like a review handler without
-     * checking the resulting score.
-     *
-     * @param label definition of the label to test values of.
-     * @param val previously denied value the user attempted.
-     * @return nearest likely allowed value, or {@code 0} if no value was identified.
-     * @throws PermissionBackendException backend cannot run test.
-     */
-    public short squashByTest(LabelType label, short val) throws PermissionBackendException {
-      return nearest(test(label), val);
-    }
-
-    private static short nearest(Iterable<LabelPermission.WithValue> possible, short wanted) {
-      short s = 0;
-      for (LabelPermission.WithValue v : possible) {
-        if ((wanted < 0 && v.value() < 0 && wanted <= v.value() && v.value() < s)
-            || (wanted > 0 && v.value() > 0 && wanted >= v.value() && v.value() > s)) {
-          s = v.value();
-        }
-      }
-      return s;
-    }
   }
 }
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
index d0abf9a..6627f76 100644
--- 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
@@ -76,7 +76,22 @@
   RUN_RECEIVE_PACK,
 
   /** Can run upload pack. */
-  RUN_UPLOAD_PACK;
+  RUN_UPLOAD_PACK,
+
+  /** Allow read access to refs/meta/config. */
+  READ_CONFIG,
+
+  /** Allow write access to refs/meta/config. */
+  WRITE_CONFIG,
+
+  /** Allow banning commits from Gerrit preventing pushes of these commits. */
+  BAN_COMMIT,
+
+  /** Allow accessing the project's reflog. */
+  READ_REFLOG,
+
+  /** Can push to at least one reference within the repository. */
+  PUSH_AT_LEAST_ONE_REF;
 
   private final String name;
 
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
index 8b5d8fb..607162e 100644
--- 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
@@ -24,6 +24,7 @@
   DELETE(Permission.DELETE),
   UPDATE(Permission.PUSH),
   FORCE_UPDATE,
+  SET_HEAD,
 
   FORGE_AUTHOR(Permission.FORGE_AUTHOR),
   FORGE_COMMITTER(Permission.FORGE_COMMITTER),
@@ -41,7 +42,19 @@
    * 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;
+  UPDATE_BY_SUBMIT,
+
+  /**
+   * Can read all private changes on the ref. Typically granted to CI systems if they should run on
+   * private changes.
+   */
+  READ_PRIVATE_CHANGES(Permission.VIEW_PRIVATE_CHANGES),
+
+  /** Read access to ref's config section in {@code project.config}. */
+  READ_CONFIG,
+
+  /** Write access to ref's config section in {@code project.config}. */
+  WRITE_CONFIG;
 
   private final String name;
 
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 a2da580..266350f 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
@@ -15,32 +15,41 @@
 package com.google.gerrit.server.plugins;
 
 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.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.plugins.DisablePlugin.Input;
+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.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
 public class DisablePlugin implements RestModifyView<PluginResource, Input> {
-  public static class Input {}
 
   private final PluginLoader loader;
+  private final Provider<IdentifiedUser> user;
+  private final PermissionBackend permissionBackend;
 
   @Inject
-  DisablePlugin(PluginLoader loader) {
+  DisablePlugin(
+      PluginLoader loader, Provider<IdentifiedUser> user, PermissionBackend permissionBackend) {
     this.loader = loader;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
-  public PluginInfo apply(PluginResource resource, Input input) throws MethodNotAllowedException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote plugin administration is disabled");
+  public PluginInfo apply(PluginResource resource, Input input) throws RestApiException {
+    try {
+      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+    } catch (PermissionBackendException e) {
+      throw new RestApiException("Could not check permission", e);
     }
+    loader.checkRemoteAdminEnabled();
     String name = resource.getName();
     loader.disablePlugins(ImmutableSet.of(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 f29e36b..569bc39 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.Input;
 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.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.plugins.EnablePlugin.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.PrintWriter;
@@ -30,7 +30,6 @@
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
 public class EnablePlugin implements RestModifyView<PluginResource, Input> {
-  public static class Input {}
 
   private final PluginLoader loader;
 
@@ -40,11 +39,8 @@
   }
 
   @Override
-  public PluginInfo apply(PluginResource resource, Input input)
-      throws ResourceConflictException, MethodNotAllowedException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote plugin administration is disabled");
-    }
+  public PluginInfo apply(PluginResource resource, Input input) throws RestApiException {
+    loader.checkRemoteAdminEnabled();
     String name = resource.getName();
     try {
       loader.enablePlugins(ImmutableSet.of(name));
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 531e9ac..ee9099e 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
@@ -19,8 +19,8 @@
 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.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.inject.Inject;
@@ -56,10 +56,8 @@
 
   @Override
   public Response<PluginInfo> apply(TopLevelResource resource, InstallPluginInput input)
-      throws BadRequestException, MethodNotAllowedException, IOException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote installation is disabled");
-    }
+      throws RestApiException, IOException {
+    loader.checkRemoteAdminEnabled();
     try {
       try (InputStream in = openStream(input)) {
         String pluginName = loader.installPluginFromStream(name, in);
@@ -104,7 +102,7 @@
 
     @Override
     public Response<PluginInfo> apply(PluginResource resource, InstallPluginInput input)
-        throws BadRequestException, MethodNotAllowedException, IOException {
+        throws RestApiException, IOException {
       return install.get().setName(resource.getName()).apply(TopLevelResource.INSTANCE, input);
     }
   }
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 d972087..954ea29 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
@@ -27,6 +27,7 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
 import com.google.gerrit.server.PluginUser;
 import com.google.gerrit.server.cache.PersistentCacheFactory;
@@ -138,6 +139,12 @@
     return remoteAdmin;
   }
 
+  public void checkRemoteAdminEnabled() throws MethodNotAllowedException {
+    if (!remoteAdmin) {
+      throw new MethodNotAllowedException("remote plugin administration is disabled");
+    }
+  }
+
   public Plugin get(String name) {
     Plugin p = running.get(name);
     if (p != null) {
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 768aa86..9dbc956 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
@@ -17,8 +17,8 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
 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.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -67,11 +67,8 @@
   }
 
   @Override
-  public InstallPlugin create(TopLevelResource parent, IdString id)
-      throws ResourceNotFoundException, MethodNotAllowedException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote installation is disabled");
-    }
+  public InstallPlugin create(TopLevelResource parent, IdString id) throws RestApiException {
+    loader.checkRemoteAdminEnabled();
     return install.get().setName(id.get()).setCreated(true);
   }
 
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 7b464bb..1134f50 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,10 +17,10 @@
 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.Input;
 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.ReloadPlugin.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.PrintWriter;
@@ -29,7 +29,6 @@
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
 public class ReloadPlugin implements RestModifyView<PluginResource, Input> {
-  public static class Input {}
 
   private final PluginLoader loader;
 
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 278b2af..c3623c6 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
@@ -15,13 +15,12 @@
 package com.google.gerrit.server.project;
 
 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.api.projects.BanCommitInput;
 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.permissions.PermissionBackendException;
 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;
@@ -34,22 +33,8 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
-public class BanCommit extends RetryingRestModifyView<ProjectResource, Input, BanResultInfo> {
-  public static class Input {
-    public List<String> commits;
-    public String reason;
-
-    public static Input fromCommits(String firstCommit, String... moreCommits) {
-      return fromCommits(Lists.asList(firstCommit, moreCommits));
-    }
-
-    public static Input fromCommits(List<String> commits) {
-      Input in = new Input();
-      in.commits = commits;
-      return in;
-    }
-  }
-
+public class BanCommit
+    extends RetryingRestModifyView<ProjectResource, BanCommitInput, BanResultInfo> {
   private final com.google.gerrit.server.git.BanCommit banCommit;
 
   @Inject
@@ -60,8 +45,8 @@
 
   @Override
   protected BanResultInfo applyImpl(
-      BatchUpdate.Factory updateFactory, ProjectResource rsrc, Input input)
-      throws RestApiException, UpdateException, IOException {
+      BatchUpdate.Factory updateFactory, ProjectResource rsrc, BanCommitInput input)
+      throws RestApiException, UpdateException, IOException, PermissionBackendException {
     BanResultInfo r = new BanResultInfo();
     if (input != null && input.commits != null && !input.commits.isEmpty()) {
       List<ObjectId> commitsToBan = new ArrayList<>(input.commits.size());
@@ -73,14 +58,11 @@
         }
       }
 
-      try {
-        BanCommitResult result = banCommit.ban(rsrc.getControl(), commitsToBan, input.reason);
-        r.newlyBanned = transformCommits(result.getNewlyBannedCommits());
-        r.alreadyBanned = transformCommits(result.getAlreadyBannedCommits());
-        r.ignored = transformCommits(result.getIgnoredObjectIds());
-      } catch (PermissionDeniedException e) {
-        throw new AuthException(e.getMessage());
-      }
+      BanCommitResult result =
+          banCommit.ban(rsrc.getNameKey(), rsrc.getUser(), commitsToBan, input.reason);
+      r.newlyBanned = transformCommits(result.getNewlyBannedCommits());
+      r.alreadyBanned = transformCommits(result.getAlreadyBannedCommits());
+      r.ignored = transformCommits(result.getIgnoredObjectIds());
     }
     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 2e81af3..622b1dd 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
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
 import org.eclipse.jgit.lib.Ref;
 
@@ -26,8 +27,8 @@
   private final String refName;
   private final String revision;
 
-  public BranchResource(ProjectControl control, Ref ref) {
-    super(control);
+  public BranchResource(ProjectState projectState, CurrentUser user, Ref ref) {
+    super(projectState, user);
     this.refName = ref.getName();
     this.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
   }
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 a40eabb..52072d8 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
@@ -85,7 +85,7 @@
           .project(project)
           .ref(ref.isSymbolic() ? ref.getTarget().getName() : ref.getName())
           .check(RefPermission.READ);
-      return new BranchResource(parent.getControl(), ref);
+      return new BranchResource(parent.getProjectState(), parent.getUser(), ref);
     } catch (AuthException notAllowed) {
       throw new ResourceNotFoundException(id);
     } catch (RepositoryNotFoundException noRepo) {
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 2203f77..63dd9a9 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,20 +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 static java.util.stream.Collectors.toList;
 
 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.PermissionRange;
 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;
@@ -46,65 +44,15 @@
 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.EnumSet;
 import java.util.Map;
 import java.util.Set;
-import java.util.function.Predicate;
 
 /** 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(Change.Id changeId, CurrentUser user) throws OrmException {
-      return validateFor(notesFactory.createChecked(changeId), user);
-    }
-
-    public ChangeControl validateFor(ChangeNotes notes, CurrentUser user) throws OrmException {
-      return controlFor(notes, user);
-    }
-  }
-
-  @Singleton
-  public static class Factory {
+  static class Factory {
     private final ChangeData.Factory changeDataFactory;
     private final ChangeNotes.Factory notesFactory;
     private final ApprovalsUtil approvalsUtil;
@@ -152,7 +100,7 @@
     this.patchSetUtil = patchSetUtil;
   }
 
-  public ChangeControl forUser(CurrentUser who) {
+  ChangeControl forUser(CurrentUser who) {
     if (getUser().equals(who)) {
       return this;
     }
@@ -160,40 +108,27 @@
         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? */
   private boolean isVisible(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
     if (getChange().isPrivate() && !isPrivateVisible(db, cd)) {
       return false;
@@ -202,36 +137,10 @@
   }
 
   /** 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, ChangeData cd) throws OrmException {
-    // TODO(hiesel) These don't need to be migrated, just remove after support for drafts is removed
-    checkArgument(
-        cd.getId().equals(ps.getId().getParentKey()), "%s not for change %s", ps, cd.getId());
-    return isVisible(cd.db());
-  }
-
-  /**
-   * @return patches for the change visible to the current user.
-   * @throws OrmException an error occurred reading the database.
-   */
-  public Collection<PatchSet> getVisiblePatchSets(Collection<PatchSet> patchSets, ReviewDb db)
-      throws OrmException {
-    // TODO(hiesel) These don't need to be migrated, just remove after support for drafts is removed
-    Predicate<? super PatchSet> predicate =
-        ps -> {
-          try {
-            return isVisible(db);
-          } catch (OrmException e) {
-            return false;
-          }
-        };
-    return patchSets.stream().filter(predicate).collect(toList());
-  }
-
   /** Can this user abandon this change? */
   private boolean canAbandon(ReviewDb db) throws OrmException {
     return (isOwner() // owner (aka creator) of the change can abandon
@@ -243,7 +152,7 @@
   }
 
   /** Can this user delete this change? */
-  public boolean canDelete(Change.Status status) {
+  private boolean canDelete(Change.Status status) {
     switch (status) {
       case NEW:
       case ABANDONED:
@@ -285,7 +194,7 @@
   }
 
   /** Is the current patch set locked against state changes? */
-  boolean isPatchSetLocked(ReviewDb db) throws OrmException {
+  private boolean isPatchSetLocked(ReviewDb db) throws OrmException {
     if (getChange().getStatus() == Change.Status.MERGED) {
       return false;
     }
@@ -300,7 +209,7 @@
               .byLabel(ap.getLabel());
       if (type != null
           && ap.getValue() == 1
-          && type.getFunctionName().equalsIgnoreCase("PatchSetLock")) {
+          && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
         return true;
       }
     }
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 0cd7d19..e008d66 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
@@ -53,7 +53,7 @@
   public ChildProjectResource parse(ProjectResource parent, IdString id)
       throws ResourceNotFoundException, IOException, PermissionBackendException {
     ProjectResource p = projectsCollection.parse(TopLevelResource.INSTANCE, id);
-    for (ProjectState pp : p.getControl().getProjectState().parents()) {
+    for (ProjectState pp : p.getProjectState().parents()) {
       if (parent.getNameKey().equals(pp.getProject().getNameKey())) {
         return new ChildProjectResource(parent, p.getProjectState());
       }
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 eb0dde4..db0787c 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
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
@@ -39,15 +40,15 @@
 public class ConfigInfoImpl extends ConfigInfo {
   public ConfigInfoImpl(
       boolean serverEnableSignedPush,
-      ProjectControl control,
+      ProjectState projectState,
+      CurrentUser user,
       TransferConfig config,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
       UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
-    ProjectState projectState = control.getProjectState();
-    Project p = control.getProject();
+    Project p = projectState.getProject();
     this.description = Strings.emptyToNull(p.getDescription());
 
     InheritedBooleanInfo useContributorAgreements = new InheritedBooleanInfo();
@@ -130,11 +131,10 @@
       this.commentlinks.put(cl.name, cl);
     }
 
-    pluginConfig =
-        getPluginConfig(control.getProjectState(), pluginConfigEntries, cfgFactory, allProjects);
+    pluginConfig = getPluginConfig(projectState, pluginConfigEntries, cfgFactory, allProjects);
 
     actions = new TreeMap<>();
-    for (UiAction.Description d : uiActions.from(views, new ProjectResource(control))) {
+    for (UiAction.Description d : uiActions.from(views, new ProjectResource(projectState, user))) {
       actions.put(d.getId(), new ActionInfo(d));
     }
     this.theme = projectState.getTheme();
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
index 326d395..31b48cd 100644
--- 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
@@ -38,6 +38,7 @@
 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.ProjectPermission;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
@@ -89,25 +90,23 @@
       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) {
+    PermissionBackend.ForProject forProject =
+        permissionBackend.user(rsrc.getUser()).project(rsrc.getNameKey());
+    if (!check(forProject, ProjectPermission.READ_CONFIG)) {
       throw new PermissionDeniedException(RefNames.REFS_CONFIG + " not visible");
     }
-    if (!rsrc.getControl().isOwner()) {
+    if (!check(forProject, ProjectPermission.WRITE_CONFIG)) {
       try {
-        metaRef.check(RefPermission.CREATE_CHANGE);
+        forProject.ref(RefNames.REFS_CONFIG).check(RefPermission.CREATE_CHANGE);
       } catch (AuthException denied) {
         throw new PermissionDeniedException("cannot create change for " + RefNames.REFS_CONFIG);
       }
     }
 
+    MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
+    List<AccessSection> removals = setAccess.getAccessSections(input.remove);
+    List<AccessSection> additions = setAccess.getAccessSections(input.add);
+
     Project.NameKey newParentProjectName =
         input.parent == null ? null : new Project.NameKey(input.parent);
 
@@ -159,4 +158,14 @@
         .setValidate(false)
         .setUpdateRef(false);
   }
+
+  private boolean check(PermissionBackend.ForProject perm, ProjectPermission p)
+      throws PermissionBackendException {
+    try {
+      perm.check(p);
+      return true;
+    } catch (AuthException denied) {
+      return false;
+    }
+  }
 }
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 9b355f1..70cb266 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
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -65,6 +66,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.locks.Lock;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -103,6 +105,7 @@
   private final Provider<IdentifiedUser> identifiedUser;
   private final Provider<PutConfig> putConfig;
   private final AllProjectsName allProjects;
+  private final DynamicItem<ProjectNameLockManager> lockManager;
   private final String name;
 
   @Inject
@@ -123,6 +126,7 @@
       Provider<IdentifiedUser> identifiedUser,
       Provider<PutConfig> putConfig,
       AllProjectsName allProjects,
+      DynamicItem<ProjectNameLockManager> lockManager,
       @Assisted String name) {
     this.projectsCollection = projectsCollection;
     this.groupsCollection = groupsCollection;
@@ -140,6 +144,7 @@
     this.identifiedUser = identifiedUser;
     this.putConfig = putConfig;
     this.allProjects = allProjects;
+    this.lockManager = lockManager;
     this.name = name;
   }
 
@@ -192,22 +197,27 @@
       throw new BadRequestException(e.getMessage());
     }
 
-    for (ProjectCreationValidationListener l : projectCreationValidationListeners) {
-      try {
-        l.validateNewProject(args);
-      } catch (ValidationException e) {
-        throw new ResourceConflictException(e.getMessage(), e);
+    Lock nameLock = lockManager.get().getLock(args.getProject());
+    nameLock.lock();
+    try {
+      for (ProjectCreationValidationListener l : projectCreationValidationListeners) {
+        try {
+          l.validateNewProject(args);
+        } catch (ValidationException e) {
+          throw new ResourceConflictException(e.getMessage(), e);
+        }
       }
-    }
 
-    ProjectState projectState = createProject(args);
-    if (input.pluginConfigValues != null) {
-      ConfigInput in = new ConfigInput();
-      in.pluginConfigValues = input.pluginConfigValues;
-      putConfig.get().apply(projectState, in);
+      ProjectState projectState = createProject(args);
+      if (input.pluginConfigValues != null) {
+        ConfigInput in = new ConfigInput();
+        in.pluginConfigValues = input.pluginConfigValues;
+        putConfig.get().apply(projectState, in);
+      }
+      return Response.created(json.format(projectState));
+    } finally {
+      nameLock.unlock();
     }
-
-    return Response.created(json.format(projectState));
   }
 
   private ProjectState createProject(CreateProjectArgs args)
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 61548c4..8e706a2 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
@@ -66,6 +66,7 @@
   private final TagCache tagCache;
   private final GitReferenceUpdated referenceUpdated;
   private final WebLinks links;
+  private final ProjectControl.GenericFactory projectControlFactory;
   private String ref;
 
   @Inject
@@ -76,6 +77,7 @@
       TagCache tagCache,
       GitReferenceUpdated referenceUpdated,
       WebLinks webLinks,
+      ProjectControl.GenericFactory projectControlFactory,
       @Assisted String ref) {
     this.permissionBackend = permissionBackend;
     this.identifiedUser = identifiedUser;
@@ -83,12 +85,13 @@
     this.tagCache = tagCache;
     this.referenceUpdated = referenceUpdated;
     this.links = webLinks;
+    this.projectControlFactory = projectControlFactory;
     this.ref = ref;
   }
 
   @Override
   public TagInfo apply(ProjectResource resource, TagInput input)
-      throws RestApiException, IOException, PermissionBackendException {
+      throws RestApiException, IOException, PermissionBackendException, NoSuchProjectException {
     if (input == null) {
       input = new TagInput();
     }
@@ -101,7 +104,11 @@
 
     ref = RefUtil.normalizeTagRef(ref);
 
-    RefControl refControl = resource.getControl().controlForRef(ref);
+    // TODO(hiesel): Remove dependency on RefControl
+    RefControl refControl =
+        projectControlFactory
+            .controlFor(resource.getNameKey(), resource.getUser())
+            .controlForRef(ref);
     PermissionBackend.ForRef perm =
         permissionBackend.user(identifiedUser).project(resource.getNameKey()).ref(ref);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardResource.java
index a3fd09e..87b6fdf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardResource.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
 import org.eclipse.jgit.lib.Config;
 
@@ -23,31 +24,38 @@
   public static final TypeLiteral<RestView<DashboardResource>> DASHBOARD_KIND =
       new TypeLiteral<RestView<DashboardResource>>() {};
 
-  public static DashboardResource projectDefault(ProjectControl ctl) {
-    return new DashboardResource(ctl, null, null, null, true);
+  public static DashboardResource projectDefault(ProjectState projectState, CurrentUser user) {
+    return new DashboardResource(projectState, user, null, null, null, true);
   }
 
-  private final ProjectControl control;
+  private final ProjectState projectState;
+  private final CurrentUser user;
   private final String refName;
   private final String pathName;
   private final Config config;
   private final boolean projectDefault;
 
   public DashboardResource(
-      ProjectControl control,
+      ProjectState projectState,
+      CurrentUser user,
       String refName,
       String pathName,
       Config config,
       boolean projectDefault) {
-    this.control = control;
+    this.projectState = projectState;
+    this.user = user;
     this.refName = refName;
     this.pathName = pathName;
     this.config = config;
     this.projectDefault = projectDefault;
   }
 
-  public ProjectControl getControl() {
-    return control;
+  public ProjectState getProjectState() {
+    return projectState;
+  }
+
+  public CurrentUser getUser() {
+    return user;
   }
 
   public String getRefName() {
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 d43a066..d5c591f 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
@@ -106,9 +106,8 @@
   public DashboardResource parse(ProjectResource parent, IdString id)
       throws ResourceNotFoundException, IOException, ConfigInvalidException,
           PermissionBackendException {
-    ProjectControl myCtl = parent.getControl();
     if (isDefaultDashboard(id)) {
-      return DashboardResource.projectDefault(myCtl);
+      return DashboardResource.projectDefault(parent.getProjectState(), parent.getUser());
     }
 
     DashboardInfo info;
@@ -118,10 +117,9 @@
       throw new ResourceNotFoundException(id);
     }
 
-    CurrentUser user = myCtl.getUser();
-    for (ProjectState ps : myCtl.getProjectState().tree()) {
+    for (ProjectState ps : parent.getProjectState().tree()) {
       try {
-        return parse(ps.controlFor(user), info, myCtl);
+        return parse(ps, parent.getProjectState(), parent.getUser(), info);
       } catch (AmbiguousObjectException | ConfigInvalidException | IncorrectObjectTypeException e) {
         throw new ResourceNotFoundException(id);
       } catch (ResourceNotFoundException e) {
@@ -138,16 +136,13 @@
     return ref;
   }
 
-  private DashboardResource parse(ProjectControl ctl, DashboardInfo info, ProjectControl myCtl)
+  private DashboardResource parse(
+      ProjectState parent, ProjectState current, CurrentUser user, DashboardInfo info)
       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);
+      permissionBackend.user(user).project(parent.getNameKey()).ref(ref).check(RefPermission.READ);
     } catch (AuthException e) {
       // Don't leak the project's existence
       throw new ResourceNotFoundException(info.id);
@@ -156,13 +151,13 @@
       throw new ResourceNotFoundException(info.id);
     }
 
-    try (Repository git = gitManager.openRepository(ctl.getProject().getNameKey())) {
+    try (Repository git = gitManager.openRepository(parent.getNameKey())) {
       ObjectId objId = git.resolve(ref + ":" + info.path);
       if (objId == null) {
         throw new ResourceNotFoundException(info.id);
       }
       BlobBasedConfig cfg = new BlobBasedConfig(null, git, objId);
-      return new DashboardResource(myCtl, ref, info.path, cfg, false);
+      return new DashboardResource(current, user, ref, info.path, cfg, false);
     } catch (RepositoryNotFoundException e) {
       throw new ResourceNotFoundException(info.id);
     }
@@ -211,8 +206,8 @@
     DashboardInfo info = newDashboardInfo(refName, path);
     info.project = project;
     info.definingProject = definingProject.getName();
-    String query = config.getString("dashboard", null, "title");
-    info.title = replace(project, query == null ? info.path : query);
+    String title = config.getString("dashboard", null, "title");
+    info.title = replace(project, title == null ? info.path : title);
     info.description = replace(project, config.getString("dashboard", null, "description"));
     info.foreach = config.getString("dashboard", null, "foreach");
 
@@ -238,8 +233,8 @@
     return info;
   }
 
-  private static String replace(String project, String query) {
-    return query.replace("${project}", project);
+  private static String replace(String project, String input) {
+    return input == null ? input : input.replace("${project}", project);
   }
 
   private static String defaultOf(Project proj) {
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
index ef5e41d..f577436 100644
--- 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
@@ -75,7 +75,7 @@
         if (state != null) {
           return state.controlFor(user).asForProject().database(db);
         }
-        return FailedPermissionBackend.project("not found");
+        return FailedPermissionBackend.project("not found", new NoSuchProjectException(project));
       } catch (IOException e) {
         return FailedPermissionBackend.project("unavailable", e);
       }
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
index 8fa049d..bdfc67f 100644
--- 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
@@ -32,7 +32,6 @@
       // TODO(sop) Hide ProjectControl, RefControl, ChangeControl related bindings.
       bind(ProjectControl.GenericFactory.class);
       factory(ProjectControl.AssistedFactory.class);
-      bind(ChangeControl.GenericFactory.class);
       bind(ChangeControl.Factory.class);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
new file mode 100644
index 0000000..8c9bec7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
@@ -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.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.AbstractModule;
+import com.google.inject.Singleton;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+@Singleton
+public class DefaultProjectNameLockManager implements ProjectNameLockManager {
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      DynamicItem.bind(binder(), ProjectNameLockManager.class)
+          .to(DefaultProjectNameLockManager.class);
+    }
+  }
+
+  LoadingCache<Project.NameKey, Lock> lockCache =
+      CacheBuilder.newBuilder()
+          .maximumSize(1024)
+          .expireAfterAccess(5, TimeUnit.MINUTES)
+          .build(
+              new CacheLoader<Project.NameKey, Lock>() {
+                @Override
+                public Lock load(Project.NameKey key) throws Exception {
+                  return new ReentrantLock();
+                }
+              });
+
+  @Override
+  public Lock getLock(Project.NameKey name) {
+    try {
+      return lockCache.get(name);
+    } catch (ExecutionException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}
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 8cd44d1..7c7d6af 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,6 +16,7 @@
 
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -24,7 +25,6 @@
 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;
 import com.google.inject.Inject;
@@ -34,7 +34,6 @@
 
 @Singleton
 public class DeleteBranch implements RestModifyView<BranchResource, Input> {
-  public static class Input {}
 
   private final Provider<InternalChangeQuery> queryProvider;
   private final DeleteRef.Factory deleteRefFactory;
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 a05fa2e..234f1d5 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,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -28,8 +29,7 @@
 import java.io.IOException;
 
 @Singleton
-public class DeleteTag implements RestModifyView<TagResource, DeleteTag.Input> {
-  public static class Input {}
+public class DeleteTag implements RestModifyView<TagResource, Input> {
 
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
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 07c52f9..6b05ca1 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
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_REF;
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static com.google.gerrit.server.permissions.RefPermission.READ;
+import static com.google.gerrit.server.permissions.RefPermission.WRITE_CONFIG;
 import static java.util.stream.Collectors.toMap;
 
 import com.google.common.collect.ImmutableBiMap;
@@ -49,6 +50,7 @@
 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;
@@ -90,7 +92,6 @@
   private final ProjectJson projectJson;
   private final ProjectCache projectCache;
   private final MetaDataUpdate.Server metaDataUpdateFactory;
-  private final ProjectControl.GenericFactory projectControlFactory;
   private final GroupBackend groupBackend;
   private final GroupJson groupJson;
 
@@ -103,7 +104,6 @@
       ProjectCache projectCache,
       MetaDataUpdate.Server metaDataUpdateFactory,
       ProjectJson projectJson,
-      ProjectControl.GenericFactory projectControlFactory,
       GroupBackend groupBackend,
       GroupJson groupJson) {
     this.user = self;
@@ -112,7 +112,6 @@
     this.allProjectsName = allProjectsName;
     this.projectJson = projectJson;
     this.projectCache = projectCache;
-    this.projectControlFactory = projectControlFactory;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.groupBackend = groupBackend;
     this.groupJson = groupJson;
@@ -121,11 +120,11 @@
   public ProjectAccessInfo apply(Project.NameKey nameKey)
       throws ResourceNotFoundException, ResourceConflictException, IOException,
           PermissionBackendException, OrmException {
-    try {
-      return apply(new ProjectResource(projectControlFactory.controlFor(nameKey, user.get())));
-    } catch (NoSuchProjectException e) {
+    ProjectState state = projectCache.checkedGet(nameKey);
+    if (state == null) {
       throw new ResourceNotFoundException(nameKey.get());
     }
+    return apply(new ProjectResource(state, user.get()));
   }
 
   @Override
@@ -138,7 +137,7 @@
 
     Project.NameKey projectName = rsrc.getNameKey();
     ProjectAccessInfo info = new ProjectAccessInfo();
-    ProjectControl pc = createProjectControl(projectName);
+    ProjectState projectState = projectCache.checkedGet(projectName);
     PermissionBackend.ForProject perm = permissionBackend.user(user).project(projectName);
 
     ProjectConfig config;
@@ -149,12 +148,12 @@
         md.setMessage("Update group names\n");
         config.commit(md);
         projectCache.evict(config.getProject());
-        pc = createProjectControl(projectName);
+        projectState = projectCache.checkedGet(projectName);
         perm = permissionBackend.user(user).project(projectName);
       } else if (config.getRevision() != null
-          && !config.getRevision().equals(pc.getProjectState().getConfig().getRevision())) {
+          && !config.getRevision().equals(projectState.getConfig().getRevision())) {
         projectCache.evict(config.getProject());
-        pc = createProjectControl(projectName);
+        projectState = projectCache.checkedGet(projectName);
         perm = permissionBackend.user(user).project(projectName);
       }
     } catch (ConfigInvalidException e) {
@@ -166,25 +165,26 @@
     info.local = new HashMap<>();
     info.ownerOf = new HashSet<>();
     Map<AccountGroup.UUID, GroupInfo> visibleGroups = new HashMap<>();
-    boolean checkReadConfig = check(perm, RefNames.REFS_CONFIG, READ);
+    boolean canReadConfig = check(perm, ProjectPermission.READ_CONFIG);
+    boolean canWriteConfig = check(perm, ProjectPermission.WRITE_CONFIG);
 
     for (AccessSection section : config.getAccessSections()) {
       String name = section.getName();
       if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
-        if (pc.isOwner()) {
+        if (canWriteConfig) {
           info.local.put(name, createAccessSection(visibleGroups, section));
           info.ownerOf.add(name);
 
-        } else if (checkReadConfig) {
+        } else if (canReadConfig) {
           info.local.put(section.getName(), createAccessSection(visibleGroups, section));
         }
 
       } else if (RefConfigSection.isValid(name)) {
-        if (pc.controlForRef(name).isOwner()) {
+        if (check(perm, name, WRITE_CONFIG)) {
           info.local.put(name, createAccessSection(visibleGroups, section));
           info.ownerOf.add(name);
 
-        } else if (checkReadConfig) {
+        } else if (canReadConfig) {
           info.local.put(name, createAccessSection(visibleGroups, section));
 
         } else if (check(perm, name, READ)) {
@@ -232,7 +232,7 @@
       info.revision = config.getRevision().name();
     }
 
-    ProjectState parent = Iterables.getFirst(pc.getProjectState().parents(), null);
+    ProjectState parent = Iterables.getFirst(projectState.parents(), null);
     if (parent != null) {
       info.inheritsFrom = projectJson.format(parent.getProject());
     }
@@ -242,13 +242,13 @@
       info.ownerOf.add(AccessSection.GLOBAL_CAPABILITIES);
     }
 
-    info.isOwner = toBoolean(pc.isOwner());
+    info.isOwner = toBoolean(canWriteConfig);
     info.canUpload =
         toBoolean(
-            pc.isOwner()
-                || (checkReadConfig && perm.ref(RefNames.REFS_CONFIG).testOrFalse(CREATE_CHANGE)));
+            canWriteConfig
+                || (canReadConfig && perm.ref(RefNames.REFS_CONFIG).testOrFalse(CREATE_CHANGE)));
     info.canAdd = toBoolean(perm.testOrFalse(CREATE_REF));
-    info.configVisible = checkReadConfig || pc.isOwner();
+    info.configVisible = canReadConfig || canWriteConfig;
 
     info.groups =
         visibleGroups
@@ -291,6 +291,16 @@
     }
   }
 
+  private static boolean check(PermissionBackend.ForProject ctx, ProjectPermission perm)
+      throws PermissionBackendException {
+    try {
+      ctx.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();
@@ -316,15 +326,6 @@
     return accessSectionInfo;
   }
 
-  private ProjectControl createProjectControl(Project.NameKey projectName)
-      throws IOException, ResourceNotFoundException {
-    try {
-      return projectControlFactory.controlFor(projectName, user.get());
-    } catch (NoSuchProjectException e) {
-      throw new ResourceNotFoundException(projectName.get());
-    }
-  }
-
   private static Boolean toBoolean(boolean value) {
     return value ? true : null;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
index b1ba281..c2f816e 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
@@ -59,7 +59,8 @@
   public ConfigInfo apply(ProjectResource resource) {
     return new ConfigInfoImpl(
         serverEnableSignedPush,
-        resource.getControl(),
+        resource.getProjectState(),
+        resource.getUser(),
         config,
         pluginConfigEntries,
         cfgFactory,
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 cdf23bb..d4d9a54 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
@@ -28,6 +28,7 @@
 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.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -52,64 +53,63 @@
   }
 
   @Override
-  public DashboardInfo apply(DashboardResource resource)
+  public DashboardInfo apply(DashboardResource rsrc)
       throws RestApiException, IOException, PermissionBackendException {
-    if (inherited && !resource.isProjectDefault()) {
+    if (inherited && !rsrc.isProjectDefault()) {
       throw new BadRequestException("inherited flag can only be used with default");
     }
 
-    String project = resource.getControl().getProject().getName();
-    if (resource.isProjectDefault()) {
+    if (rsrc.isProjectDefault()) {
       // The default is not resolved to a definition yet.
       try {
-        resource = defaultOf(resource.getControl());
+        rsrc = defaultOf(rsrc.getProjectState(), rsrc.getUser());
       } catch (ConfigInvalidException e) {
         throw new ResourceConflictException(e.getMessage());
       }
     }
 
     return DashboardsCollection.parse(
-        resource.getControl().getProject(),
-        resource.getRefName().substring(REFS_DASHBOARDS.length()),
-        resource.getPathName(),
-        resource.getConfig(),
-        project,
+        rsrc.getProjectState().getProject(),
+        rsrc.getRefName().substring(REFS_DASHBOARDS.length()),
+        rsrc.getPathName(),
+        rsrc.getConfig(),
+        rsrc.getProjectState().getName(),
         true);
   }
 
-  private DashboardResource defaultOf(ProjectControl ctl)
+  private DashboardResource defaultOf(ProjectState projectState, CurrentUser user)
       throws ResourceNotFoundException, IOException, ConfigInvalidException,
           PermissionBackendException {
-    String id = ctl.getProject().getLocalDefaultDashboard();
+    String id = projectState.getProject().getLocalDefaultDashboard();
     if (Strings.isNullOrEmpty(id)) {
-      id = ctl.getProject().getDefaultDashboard();
+      id = projectState.getProject().getDefaultDashboard();
     }
     if (isDefaultDashboard(id)) {
       throw new ResourceNotFoundException();
     } else if (!Strings.isNullOrEmpty(id)) {
-      return parse(ctl, id);
+      return parse(projectState, user, id);
     } else if (!inherited) {
       throw new ResourceNotFoundException();
     }
 
-    for (ProjectState ps : ctl.getProjectState().tree()) {
+    for (ProjectState ps : projectState.tree()) {
       id = ps.getProject().getDefaultDashboard();
       if (isDefaultDashboard(id)) {
         throw new ResourceNotFoundException();
       } else if (!Strings.isNullOrEmpty(id)) {
-        ctl = ps.controlFor(ctl.getUser());
-        return parse(ctl, id);
+        return parse(projectState, user, id);
       }
     }
     throw new ResourceNotFoundException();
   }
 
-  private DashboardResource parse(ProjectControl ctl, String id)
+  private DashboardResource parse(ProjectState projectState, CurrentUser user, String id)
       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));
-    return dashboards.parse(new ProjectResource(ctl), IdString.fromUrl(ref + ':' + path));
+    return dashboards.parse(
+        new ProjectResource(projectState, user), IdString.fromUrl(ref + ':' + path));
   }
 }
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 31dc7bf..daaf4ef 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
@@ -20,6 +20,7 @@
 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.ProjectPermission;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -72,10 +73,14 @@
           }
           throw new AuthException("not allowed to see HEAD");
         } catch (MissingObjectException | IncorrectObjectTypeException e) {
-          if (rsrc.getControl().isOwner()) {
-            return head.getObjectId().name();
+          try {
+            permissionBackend
+                .user(rsrc.getUser())
+                .project(rsrc.getNameKey())
+                .check(ProjectPermission.WRITE_CONFIG);
+          } catch (AuthException ae) {
+            throw new AuthException("not allowed to see HEAD");
           }
-          throw new AuthException("not allowed to see HEAD");
         }
       }
       throw new ResourceNotFoundException(Constants.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 8f0b6f0..df87575 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
@@ -21,7 +21,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-class GetParent implements RestReadView<ProjectResource> {
+public class GetParent implements RestReadView<ProjectResource> {
   private final AllProjectsName allProjectsName;
 
   @Inject
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetReflog.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetReflog.java
index 44d6a4f..9643e09 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetReflog.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetReflog.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
-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.RestApiException;
@@ -24,6 +23,9 @@
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.args4j.TimestampHandler;
 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.ProjectPermission;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -40,6 +42,7 @@
   private static final Logger log = LoggerFactory.getLogger(GetReflog.class);
 
   private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
 
   @Option(
     name = "--limit",
@@ -83,15 +86,18 @@
   private Timestamp to;
 
   @Inject
-  public GetReflog(GitRepositoryManager repoManager) {
+  public GetReflog(GitRepositoryManager repoManager, PermissionBackend permissionBackend) {
     this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
-  public List<ReflogEntryInfo> apply(BranchResource rsrc) throws RestApiException, IOException {
-    if (!rsrc.getControl().isOwner()) {
-      throw new AuthException("not project owner");
-    }
+  public List<ReflogEntryInfo> apply(BranchResource rsrc)
+      throws RestApiException, IOException, PermissionBackendException {
+    permissionBackend
+        .user(rsrc.getUser())
+        .project(rsrc.getNameKey())
+        .check(ProjectPermission.READ_REFLOG);
 
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
       ReflogReader r;
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 b2edc6b..645058f 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
@@ -179,7 +179,6 @@
       }
     }
 
-    ProjectControl pctl = rsrc.getControl();
     PermissionBackend.ForProject perm = permissionBackend.user(user).project(rsrc.getNameKey());
     List<BranchInfo> branches = new ArrayList<>(refs.size());
     for (Ref ref : refs) {
@@ -207,7 +206,9 @@
       }
 
       if (perm.ref(ref.getName()).test(RefPermission.READ)) {
-        branches.add(createBranchInfo(perm.ref(ref.getName()), ref, pctl, targets));
+        branches.add(
+            createBranchInfo(
+                perm.ref(ref.getName()), ref, rsrc.getProjectState(), rsrc.getUser(), targets));
       }
     }
     Collections.sort(branches, new BranchComparator());
@@ -234,14 +235,18 @@
   }
 
   private BranchInfo createBranchInfo(
-      PermissionBackend.ForRef perm, Ref ref, ProjectControl pctl, Set<String> targets) {
+      PermissionBackend.ForRef perm,
+      Ref ref,
+      ProjectState projectState,
+      CurrentUser user,
+      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()) && perm.testOrFalse(RefPermission.DELETE) ? true : null;
 
-    BranchResource rsrc = new BranchResource(pctl, ref);
+    BranchResource rsrc = new BranchResource(projectState, user, ref);
     for (UiAction.Description d : uiActions.from(branchViews, rsrc)) {
       if (info.actions == null) {
         info.actions = new TreeMap<>();
@@ -249,7 +254,7 @@
       info.actions.put(d.getId(), new ActionInfo(d));
     }
 
-    List<WebLinkInfo> links = webLinks.getBranchLinks(pctl.getProject().getName(), ref.getName());
+    List<WebLinkInfo> links = webLinks.getBranchLinks(projectState.getName(), ref.getName());
     info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
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 dc3610c..afb796e 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
@@ -355,17 +355,15 @@
           continue;
         }
 
-        final ProjectControl pctl = e.controlFor(currentUser);
         if (groupUuid != null
-            && !pctl.getProjectState()
-                .getLocalGroups()
+            && !e.getLocalGroups()
                 .contains(GroupReference.forGroup(groupsCollection.parseId(groupUuid.get())))) {
           continue;
         }
 
         ProjectInfo info = new ProjectInfo();
         if (showTree && !format.isJson()) {
-          treeMap.put(projectName, projectNodeFactory.create(pctl.getProject(), true));
+          treeMap.put(projectName, projectNodeFactory.create(e.getProject(), true));
           continue;
         }
 
@@ -396,8 +394,17 @@
               if (!type.matches(git)) {
                 continue;
               }
-
-              List<Ref> refs = getBranchRefs(projectName, pctl);
+              boolean canReadAllRefs;
+              try {
+                permissionBackend
+                    .user(currentUser)
+                    .project(e.getNameKey())
+                    .check(ProjectPermission.READ);
+                canReadAllRefs = true;
+              } catch (AuthException ae) {
+                canReadAllRefs = false;
+              }
+              List<Ref> refs = getBranchRefs(projectName, canReadAllRefs);
               if (!hasValidRef(refs)) {
                 continue;
               }
@@ -592,13 +599,13 @@
     stdout.flush();
   }
 
-  private List<Ref> getBranchRefs(Project.NameKey projectName, ProjectControl projectControl) {
+  private List<Ref> getBranchRefs(Project.NameKey projectName, boolean canReadAllRefs) {
     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 (all && projectControl.isOwner()) {
+        if (all && canReadAllRefs) {
           result[i] = ref;
         } else if (ref != null && ref.getObjectId() != null) {
           try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java
index 0f71ac8..b68446f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.Inject;
 import com.google.inject.servlet.RequestScoped;
+import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -48,7 +49,7 @@
     return ctl;
   }
 
-  public void evict(Project project) {
+  public void evict(Project project) throws IOException {
     projectCache.evict(project);
     controls.remove(project.getNameKey());
   }
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 65c7315..63052bd 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
@@ -45,17 +45,27 @@
    */
   ProjectState checkedGet(Project.NameKey projectName) throws IOException;
 
-  /** Invalidate the cached information about the given project. */
-  void evict(Project p);
+  /**
+   * Invalidate the cached information about the given project, and triggers reindexing for it
+   *
+   * @param p project that is being evicted
+   * @throws IOException thrown if the reindexing fails
+   */
+  void evict(Project p) throws IOException;
 
-  /** Invalidate the cached information about the given project. */
-  void evict(Project.NameKey p);
+  /**
+   * Invalidate the cached information about the given project, and triggers reindexing for it
+   *
+   * @param p the NameKey of the project that is being evicted
+   * @throws IOException thrown if the reindexing fails
+   */
+  void evict(Project.NameKey p) throws IOException;
 
   /**
    * Remove information about the given project from the cache. It will no longer be returned from
    * {@link #all()}.
    */
-  void remove(Project p);
+  void remove(Project p) throws IOException;
 
   /** @return sorted iteration of projects. */
   Iterable<Project.NameKey> all();
@@ -75,5 +85,5 @@
   Iterable<Project.NameKey> byName(String prefix);
 
   /** Notify the cache that a new project was constructed. */
-  void onCreateProject(Project.NameKey newProjectName);
+  void onCreateProject(Project.NameKey newProjectName) throws IOException;
 }
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 6ee143c..2b31ce3 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
@@ -28,8 +28,10 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.index.project.ProjectIndexer;
 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.internal.UniqueAnnotations;
@@ -82,6 +84,7 @@
   private final LoadingCache<ListKey, SortedSet<Project.NameKey>> list;
   private final Lock listLock;
   private final ProjectCacheClock clock;
+  private final Provider<ProjectIndexer> indexer;
 
   @Inject
   ProjectCacheImpl(
@@ -89,13 +92,15 @@
       final AllUsersName allUsersName,
       @Named(CACHE_NAME) LoadingCache<String, ProjectState> byName,
       @Named(CACHE_LIST) LoadingCache<ListKey, SortedSet<Project.NameKey>> list,
-      ProjectCacheClock clock) {
+      ProjectCacheClock clock,
+      Provider<ProjectIndexer> indexer) {
     this.allProjectsName = allProjectsName;
     this.allUsersName = allUsersName;
     this.byName = byName;
     this.list = list;
     this.listLock = new ReentrantLock(true /* fair */);
     this.clock = clock;
+    this.indexer = indexer;
   }
 
   @Override
@@ -151,22 +156,20 @@
   }
 
   @Override
-  public void evict(Project p) {
-    if (p != null) {
-      byName.invalidate(p.getNameKey().get());
-    }
+  public void evict(Project p) throws IOException {
+    evict(p.getNameKey());
   }
 
-  /** Invalidate the cached information about the given project. */
   @Override
-  public void evict(Project.NameKey p) {
+  public void evict(Project.NameKey p) throws IOException {
     if (p != null) {
       byName.invalidate(p.get());
     }
+    indexer.get().index(p);
   }
 
   @Override
-  public void remove(Project p) {
+  public void remove(Project p) throws IOException {
     listLock.lock();
     try {
       SortedSet<Project.NameKey> n = Sets.newTreeSet(list.get(ListKey.ALL));
@@ -181,7 +184,7 @@
   }
 
   @Override
-  public void onCreateProject(Project.NameKey newProjectName) {
+  public void onCreateProject(Project.NameKey newProjectName) throws IOException {
     listLock.lock();
     try {
       SortedSet<Project.NameKey> n = Sets.newTreeSet(list.get(ListKey.ALL));
@@ -192,6 +195,7 @@
     } finally {
       listLock.unlock();
     }
+    indexer.get().index(newProjectName);
   }
 
   @Override
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 7a7418c..0e62b3f 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
@@ -20,7 +20,6 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -50,7 +49,6 @@
 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 com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -71,10 +69,10 @@
 import org.slf4j.LoggerFactory;
 
 /** Access control management for a user accessing a project's data. */
-public class ProjectControl {
+class ProjectControl {
   private static final Logger log = LoggerFactory.getLogger(ProjectControl.class);
 
-  public static class GenericFactory {
+  static class GenericFactory {
     private final ProjectCache projectCache;
 
     @Inject
@@ -82,7 +80,7 @@
       projectCache = pc;
     }
 
-    public ProjectControl controlFor(Project.NameKey nameKey, CurrentUser user)
+    ProjectControl controlFor(Project.NameKey nameKey, CurrentUser user)
         throws NoSuchProjectException, IOException {
       final ProjectState p = projectCache.checkedGet(nameKey);
       if (p == null) {
@@ -92,20 +90,7 @@
     }
   }
 
-  public static class Factory {
-    private final Provider<PerRequestProjectControlCache> userCache;
-
-    @Inject
-    Factory(Provider<PerRequestProjectControlCache> uc) {
-      userCache = uc;
-    }
-
-    public ProjectControl controlFor(Project.NameKey nameKey) throws NoSuchProjectException {
-      return userCache.get().get(nameKey);
-    }
-  }
-
-  public interface AssistedFactory {
+  interface AssistedFactory {
     ProjectControl create(CurrentUser who, ProjectState ps);
   }
 
@@ -155,27 +140,27 @@
     state = ps;
   }
 
-  public ProjectControl forUser(CurrentUser who) {
+  ProjectControl forUser(CurrentUser who) {
     ProjectControl r = state.controlFor(who);
     // Not per-user, and reusing saves lookup time.
     r.allSections = allSections;
     return r;
   }
 
-  public ChangeControl controlFor(ReviewDb db, Change change) throws OrmException {
+  ChangeControl controlFor(ReviewDb db, Change change) throws OrmException {
     return changeControlFactory.create(
         controlForRef(change.getDest()), db, change.getProject(), change.getId());
   }
 
-  public ChangeControl controlFor(ChangeNotes notes) {
+  ChangeControl controlFor(ChangeNotes notes) {
     return changeControlFactory.create(controlForRef(notes.getChange().getDest()), notes);
   }
 
-  public RefControl controlForRef(Branch.NameKey ref) {
+  RefControl controlForRef(Branch.NameKey ref) {
     return controlForRef(ref.get());
   }
 
-  public RefControl controlForRef(String refName) {
+  RefControl controlForRef(String refName) {
     if (refControls == null) {
       refControls = new HashMap<>();
     }
@@ -188,20 +173,20 @@
     return ctl;
   }
 
-  public CurrentUser getUser() {
+  CurrentUser getUser() {
     return user;
   }
 
-  public ProjectState getProjectState() {
+  ProjectState getProjectState() {
     return state;
   }
 
-  public Project getProject() {
+  Project getProject() {
     return state.getProject();
   }
 
   /** Is this user a project owner? */
-  public boolean isOwner() {
+  boolean isOwner() {
     return (isDeclaredOwner() && !controlForRef("refs/*").isBlocked(Permission.OWNER)) || isAdmin();
   }
 
@@ -209,13 +194,10 @@
    * @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 Capable.OK;
+  boolean canPushToAtLeastOneRef() {
+    return canPerformOnAnyRef(Permission.PUSH)
+        || canPerformOnAnyRef(Permission.CREATE_TAG)
+        || isOwner();
   }
 
   /** Can the user run upload pack? */
@@ -390,6 +372,10 @@
     }
   }
 
+  boolean canRead() {
+    return !isHidden() && allRefsAreVisible(Collections.emptySet());
+  }
+
   ForProject asForProject() {
     return new ForProjectImpl();
   }
@@ -470,6 +456,15 @@
           return canRunReceivePack();
         case RUN_UPLOAD_PACK:
           return canRunUploadPack();
+
+        case PUSH_AT_LEAST_ONE_REF:
+          return canPushToAtLeastOneRef();
+
+        case BAN_COMMIT:
+        case READ_REFLOG:
+        case READ_CONFIG:
+        case WRITE_CONFIG:
+          return isOwner();
       }
       throw new PermissionBackendException(perm + " unsupported");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectData.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectData.java
new file mode 100644
index 0000000..407529d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectData.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.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.Project;
+
+public class ProjectData {
+  private final Project project;
+  private final ImmutableList<Project.NameKey> ancestors;
+
+  public ProjectData(Project project, Iterable<Project.NameKey> ancestors) {
+    this.project = project;
+    this.ancestors = ImmutableList.copyOf(ancestors);
+  }
+
+  public Project getProject() {
+    return project;
+  }
+
+  public ImmutableList<Project.NameKey> getAncestors() {
+    return ancestors;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNameLockManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNameLockManager.java
new file mode 100644
index 0000000..4666c32
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNameLockManager.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.server.project;
+
+import com.google.gerrit.reviewdb.client.Project;
+import java.util.concurrent.locks.Lock;
+
+public interface ProjectNameLockManager {
+  public Lock getLock(Project.NameKey name);
+}
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 a91ba62..22b7bd9 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
@@ -24,33 +24,32 @@
   public static final TypeLiteral<RestView<ProjectResource>> PROJECT_KIND =
       new TypeLiteral<RestView<ProjectResource>>() {};
 
-  private final ProjectControl control;
+  private final ProjectState projectState;
+  private final CurrentUser user;
 
-  public ProjectResource(ProjectControl control) {
-    this.control = control;
+  public ProjectResource(ProjectState projectState, CurrentUser user) {
+    this.projectState = projectState;
+    this.user = user;
   }
 
   ProjectResource(ProjectResource rsrc) {
-    this.control = rsrc.getControl();
+    this.projectState = rsrc.getProjectState();
+    this.user = rsrc.getUser();
   }
 
   public String getName() {
-    return control.getProject().getName();
+    return projectState.getName();
   }
 
   public Project.NameKey getNameKey() {
-    return control.getProject().getNameKey();
+    return projectState.getNameKey();
   }
 
   public ProjectState getProjectState() {
-    return control.getProjectState();
+    return projectState;
   }
 
   public CurrentUser getUser() {
-    return getControl().getUser();
-  }
-
-  public ProjectControl getControl() {
-    return control;
+    return user;
   }
 }
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 3015164..bd6386c 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
@@ -553,6 +553,10 @@
     }
   }
 
+  public ProjectData toProjectData() {
+    return new ProjectData(getProject(), parents().transform(s -> s.getProject().getNameKey()));
+  }
+
   private String readFile(Path p) throws IOException {
     return Files.exists(p) ? new String(Files.readAllBytes(p), UTF_8) : null;
   }
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 e0741f0..8d7b156 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,11 +14,14 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ListMultimap;
 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.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.NeedsParams;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -38,32 +41,48 @@
 
 @Singleton
 public class ProjectsCollection
-    implements RestCollection<TopLevelResource, ProjectResource>, AcceptsCreate<TopLevelResource> {
+    implements RestCollection<TopLevelResource, ProjectResource>,
+        AcceptsCreate<TopLevelResource>,
+        NeedsParams {
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<ListProjects> list;
-  private final ProjectControl.GenericFactory controlFactory;
+  private final Provider<QueryProjects> queryProjects;
+  private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final CreateProject.Factory createProjectFactory;
 
+  private boolean hasQuery;
+
   @Inject
   ProjectsCollection(
       DynamicMap<RestView<ProjectResource>> views,
       Provider<ListProjects> list,
-      ProjectControl.GenericFactory controlFactory,
+      Provider<QueryProjects> queryProjects,
+      ProjectCache projectCache,
       PermissionBackend permissionBackend,
       CreateProject.Factory factory,
       Provider<CurrentUser> user) {
     this.views = views;
     this.list = list;
-    this.controlFactory = controlFactory;
+    this.queryProjects = queryProjects;
+    this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.user = user;
     this.createProjectFactory = factory;
   }
 
   @Override
+  public void setParams(ListMultimap<String, String> params) throws BadRequestException {
+    // The --query option is defined in QueryProjects
+    this.hasQuery = params.containsKey("query");
+  }
+
+  @Override
   public RestView<TopLevelResource> list() {
+    if (hasQuery) {
+      return queryProjects.get();
+    }
     return list.get().setFormat(OutputFormat.JSON);
   }
 
@@ -120,10 +139,8 @@
     }
 
     Project.NameKey nameKey = new Project.NameKey(id);
-    ProjectControl ctl;
-    try {
-      ctl = controlFactory.controlFor(nameKey, user.get());
-    } catch (NoSuchProjectException e) {
+    ProjectState state = projectCache.checkedGet(nameKey);
+    if (state == null) {
       return null;
     }
 
@@ -134,7 +151,7 @@
         return null; // Pretend like not found on access denied.
       }
     }
-    return new ProjectResource(ctl);
+    return new ProjectResource(state, user.get());
   }
 
   @Override
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 c4a7eb4..9dd8b43 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
@@ -22,7 +22,6 @@
 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;
@@ -40,6 +39,9 @@
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.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;
@@ -68,6 +70,7 @@
   private final UiActions uiActions;
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   PutConfig(
@@ -81,7 +84,8 @@
       AllProjectsName allProjects,
       UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views,
-      Provider<CurrentUser> user) {
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend) {
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.projectCache = projectCache;
@@ -93,13 +97,13 @@
     this.uiActions = uiActions;
     this.views = views;
     this.user = user;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
-  public ConfigInfo apply(ProjectResource rsrc, ConfigInput input) throws RestApiException {
-    if (!rsrc.getControl().isOwner()) {
-      throw new AuthException("restricted to project owner");
-    }
+  public ConfigInfo apply(ProjectResource rsrc, ConfigInput input)
+      throws RestApiException, PermissionBackendException {
+    permissionBackend.user(user).project(rsrc.getNameKey()).check(ProjectPermission.WRITE_CONFIG);
     return apply(rsrc.getProjectState(), input);
   }
 
@@ -192,7 +196,8 @@
       ProjectState state = projectStateFactory.create(projectConfig);
       return new ConfigInfoImpl(
           serverEnableSignedPush,
-          state.controlFor(user.get()),
+          state,
+          user.get(),
           config,
           pluginConfigEntries,
           cfgFactory,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
index 78230bd..a2808fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
@@ -26,6 +26,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 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.ProjectPermission;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -36,25 +39,31 @@
 public class PutDescription implements RestModifyView<ProjectResource, DescriptionInput> {
   private final ProjectCache cache;
   private final MetaDataUpdate.Server updateFactory;
+  private final PermissionBackend permissionBackend;
 
   @Inject
-  PutDescription(ProjectCache cache, MetaDataUpdate.Server updateFactory) {
+  PutDescription(
+      ProjectCache cache,
+      MetaDataUpdate.Server updateFactory,
+      PermissionBackend permissionBackend) {
     this.cache = cache;
     this.updateFactory = updateFactory;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
   public Response<String> apply(ProjectResource resource, DescriptionInput input)
-      throws AuthException, ResourceConflictException, ResourceNotFoundException, IOException {
+      throws AuthException, ResourceConflictException, ResourceNotFoundException, IOException,
+          PermissionBackendException {
     if (input == null) {
       input = new DescriptionInput(); // Delete would set description to null.
     }
 
-    ProjectControl ctl = resource.getControl();
-    IdentifiedUser user = ctl.getUser().asIdentifiedUser();
-    if (!ctl.isOwner()) {
-      throw new AuthException("not project owner");
-    }
+    IdentifiedUser user = resource.getUser().asIdentifiedUser();
+    permissionBackend
+        .user(user)
+        .project(resource.getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
 
     try (MetaDataUpdate md = updateFactory.create(resource.getNameKey())) {
       ProjectConfig config = ProjectConfig.read(md);
@@ -70,7 +79,7 @@
       md.setAuthor(user);
       md.setMessage(msg);
       config.commit(md);
-      cache.evict(ctl.getProject());
+      cache.evict(resource.getProjectState().getProject());
       md.getRepository().setGitwebDescription(project.getDescription());
 
       return Strings.isNullOrEmpty(project.getDescription())
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/QueryProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/QueryProjects.java
new file mode 100644
index 0000000..998bdb2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/QueryProjects.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.common.ProjectInfo;
+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.QueryParseException;
+import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.server.index.project.ProjectIndex;
+import com.google.gerrit.server.index.project.ProjectIndexCollection;
+import com.google.gerrit.server.query.project.ProjectQueryBuilder;
+import com.google.gerrit.server.query.project.ProjectQueryProcessor;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+
+public class QueryProjects implements RestReadView<TopLevelResource> {
+  private final ProjectIndexCollection indexes;
+  private final ProjectQueryBuilder queryBuilder;
+  private final ProjectQueryProcessor queryProcessor;
+  private final ProjectJson json;
+
+  private String query;
+  private int limit;
+  private int start;
+
+  @Option(
+    name = "--query",
+    aliases = {"-q"},
+    usage = "project query"
+  )
+  public void setQuery(String query) {
+    this.query = query;
+  }
+
+  @Option(
+    name = "--limit",
+    aliases = {"-n"},
+    metaVar = "CNT",
+    usage = "maximum number of projects to list"
+  )
+  public void setLimit(int limit) {
+    this.limit = limit;
+  }
+
+  @Option(
+    name = "--start",
+    aliases = {"-S"},
+    metaVar = "CNT",
+    usage = "number of projects to skip"
+  )
+  public void setStart(int start) {
+    this.start = start;
+  }
+
+  @Inject
+  protected QueryProjects(
+      ProjectIndexCollection indexes,
+      ProjectQueryBuilder queryBuilder,
+      ProjectQueryProcessor queryProcessor,
+      ProjectJson json) {
+    this.indexes = indexes;
+    this.queryBuilder = queryBuilder;
+    this.queryProcessor = queryProcessor;
+    this.json = json;
+  }
+
+  @Override
+  public List<ProjectInfo> apply(TopLevelResource resource)
+      throws BadRequestException, MethodNotAllowedException, OrmException {
+    if (Strings.isNullOrEmpty(query)) {
+      throw new BadRequestException("missing query field");
+    }
+
+    ProjectIndex searchIndex = indexes.getSearchIndex();
+    if (searchIndex == null) {
+      throw new MethodNotAllowedException("no project index");
+    }
+
+    if (start != 0) {
+      queryProcessor.setStart(start);
+    }
+
+    if (limit != 0) {
+      queryProcessor.setUserProvidedLimit(limit);
+    }
+
+    try {
+      QueryResult<ProjectData> result = queryProcessor.query(queryBuilder.parse(query));
+      List<ProjectData> pds = result.entities();
+
+      ArrayList<ProjectInfo> projectInfos = Lists.newArrayListWithCapacity(pds.size());
+      for (ProjectData pd : pds) {
+        projectInfos.add(json.format(pd.getProject()));
+      }
+      return projectInfos;
+    } catch (QueryParseException e) {
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+}
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 a92c0b7..9a4fe96 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
@@ -44,7 +44,7 @@
 import java.util.Set;
 
 /** Manages access control for Git references (aka branches, tags). */
-public class RefControl {
+class RefControl {
   private final ProjectControl projectControl;
   private final String refName;
 
@@ -66,19 +66,19 @@
     this.effective = new HashMap<>();
   }
 
-  public String getRefName() {
+  String getRefName() {
     return refName;
   }
 
-  public ProjectControl getProjectControl() {
+  ProjectControl getProjectControl() {
     return projectControl;
   }
 
-  public CurrentUser getUser() {
+  CurrentUser getUser() {
     return projectControl.getUser();
   }
 
-  public RefControl forUser(CurrentUser who) {
+  RefControl forUser(CurrentUser who) {
     ProjectControl newCtl = projectControl.forUser(who);
     if (relevant.isUserSpecific()) {
       return newCtl.controlForRef(getRefName());
@@ -87,7 +87,7 @@
   }
 
   /** Is this user a ref owner? */
-  public boolean isOwner() {
+  boolean isOwner() {
     if (owner == null) {
       if (canPerform(Permission.OWNER)) {
         owner = true;
@@ -109,11 +109,6 @@
     return isVisible;
   }
 
-  /** Can this user see other users change edits? */
-  public boolean isEditVisible() {
-    return canViewPrivateChanges();
-  }
-
   private boolean canUpload() {
     return projectControl.controlForRef("refs/for/" + getRefName()).canPerform(Permission.PUSH)
         && isProjectStatePermittingWrite();
@@ -275,11 +270,6 @@
     return canPerform(Permission.ABANDON);
   }
 
-  /** @return true if this user can remove a reviewer for a change. */
-  boolean canRemoveReviewer() {
-    return canPerform(Permission.REMOVE_REVIEWER);
-  }
-
   /** @return true if this user can view private changes. */
   boolean canViewPrivateChanges() {
     return canPerform(Permission.VIEW_PRIVATE_CHANGES);
@@ -395,7 +385,7 @@
   }
 
   /** True if the user is blocked from using this permission. */
-  public boolean isBlocked(String permissionName) {
+  boolean isBlocked(String permissionName) {
     return !doCanPerform(permissionName, false, true);
   }
 
@@ -561,6 +551,8 @@
           return canUpdate();
         case FORCE_UPDATE:
           return canForceUpdate();
+        case SET_HEAD:
+          return projectControl.isOwner();
 
         case FORGE_AUTHOR:
           return canForgeAuthor();
@@ -577,6 +569,16 @@
         case UPDATE_BY_SUBMIT:
           return projectControl.controlForRef("refs/for/" + getRefName()).canSubmit(true);
 
+        case READ_PRIVATE_CHANGES:
+          return canViewPrivateChanges();
+
+        case READ_CONFIG:
+          return projectControl
+              .controlForRef(RefNames.REFS_CONFIG)
+              .canPerform(RefPermission.READ.name());
+        case WRITE_CONFIG:
+          return isOwner();
+
         case SKIP_VALIDATION:
           return canForgeAuthor()
               && canForgeCommitter()
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefResource.java
index 124439f..ac2735d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefResource.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.server.CurrentUser;
+
 public abstract class RefResource extends ProjectResource {
 
-  public RefResource(ProjectControl control) {
-    super(control);
+  public RefResource(ProjectState projectState, CurrentUser user) {
+    super(projectState, user);
   }
 
   /** @return the ref's name */
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
index ded2bf8..8f980ee 100644
--- 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
@@ -29,27 +29,28 @@
 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 ChangeControl.GenericFactory changeControlFactory;
+  private final ProjectControl.GenericFactory projectControlFactory;
 
   @Inject
   RemoveReviewerControl(
       PermissionBackend permissionBackend,
       Provider<ReviewDb> dbProvider,
-      ChangeControl.GenericFactory changeControlFactory) {
+      ProjectControl.GenericFactory projectControlFactory) {
     this.permissionBackend = permissionBackend;
     this.dbProvider = dbProvider;
-    this.changeControlFactory = changeControlFactory;
+    this.projectControlFactory = projectControlFactory;
   }
 
   /** @throws AuthException if this user is not allowed to remove this approval. */
   public void checkRemoveReviewer(
       ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
-      throws PermissionBackendException, AuthException, NoSuchChangeException, OrmException {
+      throws PermissionBackendException, AuthException, NoSuchProjectException, IOException {
     if (canRemoveReviewerWithoutPermissionCheck(
         notes.getChange(), currentUser, approval.getAccountId(), approval.getValue())) {
       return;
@@ -65,7 +66,7 @@
   /** @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, NoSuchChangeException, OrmException {
+      throws PermissionBackendException, NoSuchProjectException, OrmException, IOException {
     if (canRemoveReviewerWithoutPermissionCheck(cd.change(), currentUser, reviewer, value)) {
       return true;
     }
@@ -78,7 +79,7 @@
 
   private boolean canRemoveReviewerWithoutPermissionCheck(
       Change change, CurrentUser currentUser, Account.Id reviewer, int value)
-      throws NoSuchChangeException, OrmException {
+      throws NoSuchProjectException, IOException {
     if (!change.getStatus().isOpen()) {
       return false;
     }
@@ -94,11 +95,11 @@
 
     // Users with the remove reviewer permission, the branch owner, project
     // owner and site admin can remove anyone
-    ChangeControl changeControl =
-        changeControlFactory.controlFor(dbProvider.get(), change, currentUser);
-    if (changeControl.getRefControl().isOwner() // branch owner
-        || changeControl.getProjectControl().isOwner() // project owner
-        || changeControl.getProjectControl().isAdmin()) { // project admin
+    // TODO(hiesel): Remove all Control usage
+    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/SetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
index e875388..c768315 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
@@ -34,6 +34,7 @@
 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;
@@ -93,9 +94,12 @@
             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 permissions for ref: " + section.getName());
+        } else {
+          permissionBackend
+              .user(identifiedUser)
+              .project(rsrc.getNameKey())
+              .ref(section.getName())
+              .check(RefPermission.WRITE_CONFIG);
         }
       }
 
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 9aa9ae7..0dd5f85 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
@@ -18,7 +18,6 @@
 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;
@@ -29,7 +28,9 @@
 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.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.io.IOException;
@@ -42,6 +43,7 @@
   private final MetaDataUpdate.Server updateFactory;
   private final DashboardsCollection dashboards;
   private final Provider<GetDashboard> get;
+  private final PermissionBackend permissionBackend;
 
   @Option(name = "--inherited", usage = "set dashboard inherited by children")
   private boolean inherited;
@@ -51,30 +53,35 @@
       ProjectCache cache,
       MetaDataUpdate.Server updateFactory,
       DashboardsCollection dashboards,
-      Provider<GetDashboard> get) {
+      Provider<GetDashboard> get,
+      PermissionBackend permissionBackend) {
     this.cache = cache;
     this.updateFactory = updateFactory;
     this.dashboards = dashboards;
     this.get = get;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
-  public Response<DashboardInfo> apply(DashboardResource resource, SetDashboardInput input)
+  public Response<DashboardInfo> apply(DashboardResource rsrc, SetDashboardInput input)
       throws RestApiException, IOException, PermissionBackendException {
     if (input == null) {
       input = new SetDashboardInput(); // Delete would set input to null.
     }
     input.id = Strings.emptyToNull(input.id);
 
-    ProjectControl ctl = resource.getControl();
-    if (!ctl.isOwner()) {
-      throw new AuthException("not project owner");
-    }
+    permissionBackend
+        .user(rsrc.getUser())
+        .project(rsrc.getProjectState().getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
 
     DashboardResource target = null;
     if (input.id != null) {
       try {
-        target = dashboards.parse(new ProjectResource(ctl), IdString.fromUrl(input.id));
+        target =
+            dashboards.parse(
+                new ProjectResource(rsrc.getProjectState(), rsrc.getUser()),
+                IdString.fromUrl(input.id));
       } catch (ResourceNotFoundException e) {
         throw new BadRequestException("dashboard " + input.id + " not found");
       } catch (ConfigInvalidException e) {
@@ -82,7 +89,7 @@
       }
     }
 
-    try (MetaDataUpdate md = updateFactory.create(ctl.getProject().getNameKey())) {
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getProjectState().getNameKey())) {
       ProjectConfig config = ProjectConfig.read(md);
       Project project = config.getProject();
       if (inherited) {
@@ -100,10 +107,10 @@
       if (!msg.endsWith("\n")) {
         msg += "\n";
       }
-      md.setAuthor(ctl.getUser().asIdentifiedUser());
+      md.setAuthor(rsrc.getUser().asIdentifiedUser());
       md.setMessage(msg);
       config.commit(md);
-      cache.evict(ctl.getProject());
+      cache.evict(rsrc.getProjectState().getProject());
 
       if (target != null) {
         DashboardInfo info = get.get().apply(target);
@@ -112,7 +119,7 @@
       }
       return Response.none();
     } catch (RepositoryNotFoundException notFound) {
-      throw new ResourceNotFoundException(ctl.getProject().getName());
+      throw new ResourceNotFoundException(rsrc.getProjectState().getProject().getName());
     } catch (ConfigInvalidException e) {
       throw new ResourceConflictException(
           String.format("invalid project.config: %s", e.getMessage()));
@@ -135,7 +142,8 @@
         throws RestApiException, IOException, PermissionBackendException {
       SetDefaultDashboard set = setDefault.get();
       set.inherited = inherited;
-      return set.apply(DashboardResource.projectDefault(resource.getControl()), input);
+      return set.apply(
+          DashboardResource.projectDefault(resource.getProjectState(), resource.getUser()), 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 eeb47df..a8d93c1 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
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.projects.HeadInput;
 import com.google.gerrit.extensions.events.HeadUpdatedListener;
 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.DefaultInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -28,7 +28,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.SetHead.Input;
+import com.google.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;
@@ -43,39 +45,41 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class SetHead implements RestModifyView<ProjectResource, Input> {
+public class SetHead implements RestModifyView<ProjectResource, HeadInput> {
   private static final Logger log = LoggerFactory.getLogger(SetHead.class);
 
-  public static class Input {
-    @DefaultInput public String ref;
-  }
-
   private final GitRepositoryManager repoManager;
   private final Provider<IdentifiedUser> identifiedUser;
   private final DynamicSet<HeadUpdatedListener> headUpdatedListeners;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   SetHead(
       GitRepositoryManager repoManager,
       Provider<IdentifiedUser> identifiedUser,
-      DynamicSet<HeadUpdatedListener> headUpdatedListeners) {
+      DynamicSet<HeadUpdatedListener> headUpdatedListeners,
+      PermissionBackend permissionBackend) {
     this.repoManager = repoManager;
     this.identifiedUser = identifiedUser;
     this.headUpdatedListeners = headUpdatedListeners;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
-  public String apply(ProjectResource rsrc, Input input)
+  public String apply(ProjectResource rsrc, HeadInput input)
       throws AuthException, ResourceNotFoundException, BadRequestException,
-          UnprocessableEntityException, IOException {
-    if (!rsrc.getControl().isOwner()) {
-      throw new AuthException("restricted to project owner");
-    }
+          UnprocessableEntityException, IOException, PermissionBackendException {
     if (input == null || Strings.isNullOrEmpty(input.ref)) {
       throw new BadRequestException("ref required");
     }
     String ref = RefNames.fullName(input.ref);
 
+    permissionBackend
+        .user(rsrc.getUser())
+        .project(rsrc.getNameKey())
+        .ref(ref)
+        .check(RefPermission.SET_HEAD);
+
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
       Map<String, Ref> cur = repo.getRefDatabase().exactRef(Constants.HEAD, ref);
       if (!cur.containsKey(ref)) {
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 37cfcdd..370af94 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
@@ -19,8 +19,8 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -33,7 +33,6 @@
 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;
 import java.io.IOException;
@@ -41,12 +40,7 @@
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
 @Singleton
-public class SetParent implements RestModifyView<ProjectResource, Input> {
-  public static class Input {
-    @DefaultInput public String parent;
-    public String commitMessage;
-  }
-
+public class SetParent implements RestModifyView<ProjectResource, ParentInput> {
   private final ProjectCache cache;
   private final PermissionBackend permissionBackend;
   private final MetaDataUpdate.Server updateFactory;
@@ -65,13 +59,13 @@
   }
 
   @Override
-  public String apply(ProjectResource rsrc, Input input)
+  public String apply(ProjectResource rsrc, ParentInput input)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
           UnprocessableEntityException, IOException, PermissionBackendException {
     return apply(rsrc, input, true);
   }
 
-  public String apply(ProjectResource rsrc, Input input, boolean checkIfAdmin)
+  public String apply(ProjectResource rsrc, ParentInput input, boolean checkIfAdmin)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
           UnprocessableEntityException, IOException, PermissionBackendException {
     IdentifiedUser user = rsrc.getUser().asIdentifiedUser();
@@ -124,6 +118,10 @@
         throw new UnprocessableEntityException("parent project " + newParent + " not found");
       }
 
+      if (parent.getName().equals(project.get())) {
+        throw new ResourceConflictException("cannot set parent to self");
+      }
+
       if (Iterables.tryFind(
               parent.tree(),
               p -> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagResource.java
index fe4d68d..08ef669 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagResource.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
 
 public class TagResource extends RefResource {
@@ -24,8 +25,8 @@
 
   private final TagInfo tagInfo;
 
-  public TagResource(ProjectControl control, TagInfo tagInfo) {
-    super(control);
+  public TagResource(ProjectState projectState, CurrentUser user, TagInfo tagInfo) {
+    super(projectState, user);
     this.tagInfo = tagInfo;
   }
 
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 78670ad..7ee0a8e 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
@@ -48,9 +48,9 @@
   }
 
   @Override
-  public TagResource parse(ProjectResource resource, IdString id)
+  public TagResource parse(ProjectResource rsrc, IdString id)
       throws ResourceNotFoundException, IOException {
-    return new TagResource(resource.getControl(), list.get().get(resource, id));
+    return new TagResource(rsrc.getProjectState(), rsrc.getUser(), list.get().get(rsrc, id));
   }
 
   @Override
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 2a71258..dfcc999 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
@@ -72,7 +72,6 @@
 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;
@@ -326,7 +325,7 @@
     ChangeData cd =
         new ChangeData(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, null, project, id, null, null);
+            null, null, null, null, project, id, null, null);
     cd.currentPatchSet = new PatchSet(new PatchSet.Id(id, currentPatchSetId));
     return cd;
   }
@@ -349,7 +348,6 @@
   private final TrackingFooters trackingFooters;
   private final GetPureRevert pureRevert;
   private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
-  private final ChangeControl.GenericFactory changeControlFactory;
 
   // Required assisted injected fields.
   private final ReviewDb db;
@@ -375,7 +373,6 @@
   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;
@@ -420,7 +417,6 @@
       TrackingFooters trackingFooters,
       GetPureRevert pureRevert,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
-      ChangeControl.GenericFactory changeControlFactory,
       @Assisted ReviewDb db,
       @Assisted Project.NameKey project,
       @Assisted Change.Id id,
@@ -443,7 +439,6 @@
     this.trackingFooters = trackingFooters;
     this.pureRevert = pureRevert;
     this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
-    this.changeControlFactory = changeControlFactory;
 
     // 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
@@ -554,22 +549,8 @@
     return visibleTo == user;
   }
 
-  private ChangeControl changeControl() throws OrmException {
-    // TODO(hiesel): Remove this method.
-    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;
-  }
-
-  void cacheVisibleTo(ChangeControl ctl) {
-    visibleTo = ctl.getUser();
-    changeControl = ctl;
+  void cacheVisibleTo(CurrentUser user) {
+    visibleTo = user;
   }
 
   public Change change() throws OrmException {
@@ -980,15 +961,8 @@
           return null;
         }
         PatchSet ps = currentPatchSet();
-        try {
-          if (ps == null || !changeControl().isVisible(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())) {
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 7bbb27b..19549d9 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
@@ -23,28 +23,28 @@
 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.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class ChangeIsVisibleToPredicate extends IsVisibleToPredicate<ChangeData> {
+  private static final Logger logger = LoggerFactory.getLogger(ChangeIsVisibleToPredicate.class);
+
   protected final Provider<ReviewDb> db;
   protected final ChangeNotes.Factory notesFactory;
-  protected final ChangeControl.GenericFactory changeControlFactory;
   protected final CurrentUser user;
   protected final PermissionBackend permissionBackend;
 
   public ChangeIsVisibleToPredicate(
       Provider<ReviewDb> db,
       ChangeNotes.Factory notesFactory,
-      ChangeControl.GenericFactory changeControlFactory,
       CurrentUser user,
       PermissionBackend permissionBackend) {
     super(ChangeQueryBuilder.FIELD_VISIBLETO, IndexUtils.describe(user));
     this.db = db;
     this.notesFactory = notesFactory;
-    this.changeControlFactory = changeControlFactory;
     this.user = user;
     this.permissionBackend = permissionBackend;
   }
@@ -59,15 +59,7 @@
       return false;
     }
 
-    ChangeControl changeControl;
     ChangeNotes notes = notesFactory.createFromIndexedChange(change);
-    try {
-      changeControl = changeControlFactory.controlFor(notes, user);
-    } catch (NoSuchChangeException e) {
-      // Ignored
-      return false;
-    }
-
     boolean visible;
     try {
       visible =
@@ -77,10 +69,14 @@
               .database(db)
               .test(ChangePermission.READ);
     } catch (PermissionBackendException e) {
+      if (e.getCause() instanceof NoSuchProjectException) {
+        logger.info("No such project: {}", cd.project());
+        return false;
+      }
       throw new OrmException("unable to check permissions", e);
     }
     if (visible) {
-      cd.cacheVisibleTo(changeControl);
+      cd.cacheVisibleTo(user);
       return true;
     }
     return false;
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 1f28dbd..ccc11aa 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
@@ -70,7 +70,6 @@
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ListChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtorm.server.OrmException;
@@ -196,7 +195,6 @@
     final AllProjectsName allProjectsName;
     final AllUsersName allUsersName;
     final PermissionBackend permissionBackend;
-    final ChangeControl.GenericFactory changeControlGenericFactory;
     final ChangeData.Factory changeDataFactory;
     final ChangeIndex index;
     final ChangeIndexRewriter rewriter;
@@ -233,7 +231,6 @@
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
         PermissionBackend permissionBackend,
-        ChangeControl.GenericFactory changeControlGenericFactory,
         ChangeNotes.Factory notesFactory,
         ChangeData.Factory changeDataFactory,
         CommentsUtil commentsUtil,
@@ -263,7 +260,6 @@
           userFactory,
           self,
           permissionBackend,
-          changeControlGenericFactory,
           notesFactory,
           changeDataFactory,
           commentsUtil,
@@ -295,7 +291,6 @@
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
         PermissionBackend permissionBackend,
-        ChangeControl.GenericFactory changeControlGenericFactory,
         ChangeNotes.Factory notesFactory,
         ChangeData.Factory changeDataFactory,
         CommentsUtil commentsUtil,
@@ -324,7 +319,6 @@
       this.self = self;
       this.permissionBackend = permissionBackend;
       this.notesFactory = notesFactory;
-      this.changeControlGenericFactory = changeControlGenericFactory;
       this.changeDataFactory = changeDataFactory;
       this.commentsUtil = commentsUtil;
       this.accountResolver = accountResolver;
@@ -357,7 +351,6 @@
           userFactory,
           Providers.of(otherUser),
           permissionBackend,
-          changeControlGenericFactory,
           notesFactory,
           changeDataFactory,
           commentsUtil,
@@ -483,7 +476,10 @@
           new ChangeIdPredicate(parseChangeId(triplet.get().id().get())));
     }
     if (PAT_LEGACY_ID.matcher(query).matches()) {
-      return new LegacyChangeIdPredicate(Change.Id.parse(query));
+      Integer id = Ints.tryParse(query);
+      if (id != null) {
+        return new LegacyChangeIdPredicate(new Change.Id(id));
+      }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
       return new ChangeIdPredicate(parseChangeId(query));
     }
@@ -926,8 +922,7 @@
   }
 
   public Predicate<ChangeData> visibleto(CurrentUser user) {
-    return new ChangeIsVisibleToPredicate(
-        args.db, args.notesFactory, args.changeControlGenericFactory, user, args.permissionBackend);
+    return new ChangeIsVisibleToPredicate(args.db, args.notesFactory, user, args.permissionBackend);
   }
 
   public Predicate<ChangeData> is_visible() throws QueryParseException {
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 eb6cf77..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
@@ -34,7 +34,6 @@
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.ArrayList;
@@ -61,7 +60,6 @@
 
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> userProvider;
-  private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeNotes.Factory notesFactory;
   private final DynamicMap<ChangeAttributeFactory> attributeFactories;
   private final PermissionBackend permissionBackend;
@@ -82,7 +80,6 @@
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
       Provider<ReviewDb> db,
-      ChangeControl.GenericFactory changeControlFactory,
       ChangeNotes.Factory notesFactory,
       DynamicMap<ChangeAttributeFactory> attributeFactories,
       PermissionBackend permissionBackend) {
@@ -96,7 +93,6 @@
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.db = db;
     this.userProvider = userProvider;
-    this.changeControlFactory = changeControlFactory;
     this.notesFactory = notesFactory;
     this.attributeFactories = attributeFactories;
     this.permissionBackend = permissionBackend;
@@ -142,8 +138,7 @@
   protected Predicate<ChangeData> enforceVisibility(Predicate<ChangeData> pred) {
     return new AndChangeSource(
         pred,
-        new ChangeIsVisibleToPredicate(
-            db, notesFactory, changeControlFactory, userProvider.get(), permissionBackend),
+        new ChangeIsVisibleToPredicate(db, notesFactory, userProvider.get(), permissionBackend),
         start);
   }
 }
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 1917d6f..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
@@ -79,7 +79,7 @@
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
         hasVote = true;
-        if (match(object, p.getValue(), p.getAccountId(), labelType)) {
+        if (match(object, p.getValue(), p.getAccountId())) {
           return true;
         }
       }
@@ -105,7 +105,7 @@
     return null;
   }
 
-  protected boolean match(ChangeData cd, short value, Account.Id approver, LabelType type) {
+  protected boolean match(ChangeData cd, short value, Account.Id approver) {
     if (value != expVal) {
       return false;
     }
@@ -119,11 +119,11 @@
       return false;
     }
 
-    // Double check the value is still permitted for the user.
+    // Check the user has 'READ' permission.
     try {
       PermissionBackend.ForChange perm =
           permissionBackend.user(reviewer).database(dbProvider).change(cd);
-      return perm.test(ChangePermission.READ) && expVal == perm.squashByTest(type, value);
+      return perm.test(ChangePermission.READ);
     } catch (PermissionBackendException e) {
       return false;
     }
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 c9ddfb7..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
@@ -24,7 +24,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Provider;
@@ -38,7 +37,6 @@
   protected static class Args {
     protected final ProjectCache projectCache;
     protected final PermissionBackend permissionBackend;
-    protected final ChangeControl.GenericFactory ccFactory;
     protected final IdentifiedUser.GenericFactory userFactory;
     protected final Provider<ReviewDb> dbProvider;
     protected final String value;
@@ -48,7 +46,6 @@
     protected Args(
         ProjectCache projectCache,
         PermissionBackend permissionBackend,
-        ChangeControl.GenericFactory ccFactory,
         IdentifiedUser.GenericFactory userFactory,
         Provider<ReviewDb> dbProvider,
         String value,
@@ -56,7 +53,6 @@
         AccountGroup.UUID group) {
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
-      this.ccFactory = ccFactory;
       this.userFactory = userFactory;
       this.dbProvider = dbProvider;
       this.value = value;
@@ -87,14 +83,7 @@
     super(
         predicates(
             new Args(
-                a.projectCache,
-                a.permissionBackend,
-                a.changeControlGenericFactory,
-                a.userFactory,
-                a.db,
-                value,
-                accounts,
-                group)));
+                a.projectCache, a.permissionBackend, a.userFactory, a.db, value, accounts, group)));
     this.value = value;
   }
 
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 ee9c570..185517a 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
@@ -31,7 +31,6 @@
 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.gson.Gson;
 import com.google.gwtorm.server.OrmException;
@@ -42,17 +41,19 @@
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.lang.reflect.Field;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.io.DisabledOutputStream;
-import org.joda.time.format.DateTimeFormat;
-import org.joda.time.format.DateTimeFormatter;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -65,7 +66,10 @@
 public class OutputStreamQuery {
   private static final Logger log = LoggerFactory.getLogger(OutputStreamQuery.class);
 
-  private static final DateTimeFormatter dtf = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss zzz");
+  private static final DateTimeFormatter dtf =
+      DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss zzz")
+          .withLocale(Locale.US)
+          .withZone(ZoneId.systemDefault());
 
   public enum OutputFormat {
     TEXT,
@@ -79,7 +83,6 @@
   private final EventFactory eventFactory;
   private final TrackingFooters trackingFooters;
   private final CurrentUser user;
-  private final ChangeControl.GenericFactory changeControlFactory;
   private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
 
   private OutputFormat outputFormat = OutputFormat.TEXT;
@@ -105,7 +108,6 @@
       EventFactory eventFactory,
       TrackingFooters trackingFooters,
       CurrentUser user,
-      ChangeControl.GenericFactory changeControlFactory,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
     this.db = db;
     this.repoManager = repoManager;
@@ -114,7 +116,6 @@
     this.eventFactory = eventFactory;
     this.trackingFooters = trackingFooters;
     this.user = user;
-    this.changeControlFactory = changeControlFactory;
     this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
   }
 
@@ -273,13 +274,12 @@
       }
     }
 
-    ChangeControl ctl = changeControlFactory.controlFor(db, d.change(), user);
     if (includePatchSets) {
       eventFactory.addPatchSets(
           db,
           rw,
           c,
-          ctl.getVisiblePatchSets(d.patchSets(), db),
+          d.patchSets(),
           includeApprovals ? d.approvals().asMap() : null,
           includeFiles,
           d.change(),
@@ -288,7 +288,7 @@
 
     if (includeCurrentPatchSet) {
       PatchSet current = d.currentPatchSet();
-      if (current != null && ctl.isVisible(d.db())) {
+      if (current != null) {
         c.currentPatchSet = eventFactory.asPatchSetAttribute(db, rw, d.change(), current);
         eventFactory.addApprovals(c.currentPatchSet, d.currentApprovals(), labelTypes);
 
@@ -308,7 +308,7 @@
             db,
             rw,
             c,
-            ctl.getVisiblePatchSets(d.patchSets(), db),
+            d.patchSets(),
             includeApprovals ? d.approvals().asMap() : null,
             includeFiles,
             d.change(),
@@ -402,7 +402,7 @@
       out.print('\n');
     } else if (value instanceof Long && isDateField(field)) {
       out.print(' ');
-      out.print(dtf.print(((Long) value) * 1000L));
+      out.print(dtf.format(Instant.ofEpochSecond((Long) value)));
       out.print('\n');
     } else if (isPrimitive(value)) {
       out.print(' ');
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 19c0515..e3ff21a 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
@@ -58,7 +58,7 @@
     List<Predicate<ChangeData>> r = new ArrayList<>();
     r.add(new ProjectPredicate(projectState.getName()));
     try {
-      ProjectResource proj = new ProjectResource(projectState.controlFor(self.get()));
+      ProjectResource proj = new ProjectResource(projectState, self.get());
       ListChildProjects children = listChildProjects.get();
       children.setRecursive(true);
       for (ProjectInfo p : children.apply(proj)) {
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 8ad0e0b..fadc853 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
@@ -34,8 +34,12 @@
 import java.util.EnumSet;
 import java.util.List;
 import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class QueryChanges implements RestReadView<TopLevelResource> {
+  private static final Logger log = LoggerFactory.getLogger(QueryChanges.class);
+
   private final ChangeJson.Factory json;
   private final ChangeQueryBuilder qb;
   private final ChangeQueryProcessor imp;
@@ -108,6 +112,7 @@
     } catch (QueryRequiresAuthException e) {
       throw new AuthException("Must be signed-in to use this operator");
     } catch (QueryParseException e) {
+      log.debug("Reject change query with 400 Bad Request: " + queries, e);
       throw new BadRequestException(e.getMessage(), e);
     }
     return out.size() == 1 ? out.get(0) : out;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
new file mode 100644
index 0000000..20032ce
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.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.server.query.project;
+
+import com.google.gerrit.index.query.IsVisibleToPredicate;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.gerrit.server.query.account.AccountQueryBuilder;
+import com.google.gwtorm.server.OrmException;
+
+public class ProjectIsVisibleToPredicate extends IsVisibleToPredicate<ProjectData> {
+  protected final PermissionBackend permissionBackend;
+  protected final CurrentUser user;
+
+  public ProjectIsVisibleToPredicate(PermissionBackend permissionBackend, CurrentUser user) {
+    super(AccountQueryBuilder.FIELD_VISIBLETO, IndexUtils.describe(user));
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+  }
+
+  @Override
+  public boolean match(ProjectData pd) throws OrmException {
+    return permissionBackend
+        .user(user)
+        .project(pd.getProject().getNameKey())
+        .testOrFalse(ProjectPermission.READ);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectPredicates.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectPredicates.java
new file mode 100644
index 0000000..379c564
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectPredicates.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.query.project;
+
+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.Project;
+import com.google.gerrit.server.index.project.ProjectField;
+import com.google.gerrit.server.project.ProjectData;
+import java.util.Locale;
+
+public class ProjectPredicates {
+  public static Predicate<ProjectData> name(Project.NameKey nameKey) {
+    return new ProjectPredicate(ProjectField.NAME, nameKey.get());
+  }
+
+  public static Predicate<ProjectData> inname(String name) {
+    return new ProjectPredicate(ProjectField.NAME_PART, name.toLowerCase(Locale.US));
+  }
+
+  public static Predicate<ProjectData> description(String description) {
+    return new ProjectPredicate(ProjectField.DESCRIPTION, description);
+  }
+
+  static class ProjectPredicate extends IndexPredicate<ProjectData> {
+    ProjectPredicate(FieldDef<ProjectData, ?> def, String value) {
+      super(def, value);
+    }
+  }
+
+  private ProjectPredicates() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
new file mode 100644
index 0000000..e9e9c0f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.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.query.project;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
+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.Project;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.inject.Inject;
+import java.util.List;
+
+/** Parses a query string meant to be applied to project objects. */
+public class ProjectQueryBuilder extends QueryBuilder<ProjectData> {
+  public static final String FIELD_LIMIT = "limit";
+
+  private static final QueryBuilder.Definition<ProjectData, ProjectQueryBuilder> mydef =
+      new QueryBuilder.Definition<>(ProjectQueryBuilder.class);
+
+  @Inject
+  ProjectQueryBuilder() {
+    super(mydef);
+  }
+
+  @Operator
+  public Predicate<ProjectData> name(String name) {
+    return ProjectPredicates.name(new Project.NameKey(name));
+  }
+
+  @Operator
+  public Predicate<ProjectData> inname(String namePart) {
+    if (namePart.isEmpty()) {
+      return name(namePart);
+    }
+    return ProjectPredicates.inname(namePart);
+  }
+
+  @Operator
+  public Predicate<ProjectData> description(String description) throws QueryParseException {
+    if (Strings.isNullOrEmpty(description)) {
+      throw error("description operator requires a value");
+    }
+
+    return ProjectPredicates.description(description);
+  }
+
+  @Override
+  protected Predicate<ProjectData> defaultField(String query) throws QueryParseException {
+    // Adapt the capacity of this list when adding more default predicates.
+    List<Predicate<ProjectData>> preds = Lists.newArrayListWithCapacity(3);
+    preds.add(name(query));
+    preds.add(inname(query));
+    if (!Strings.isNullOrEmpty(query)) {
+      preds.add(description(query));
+    }
+    return Predicate.or(preds);
+  }
+
+  @Operator
+  public Predicate<ProjectData> limit(String query) throws QueryParseException {
+    Integer limit = Ints.tryParse(query);
+    if (limit == null) {
+      throw error("Invalid limit: " + query);
+    }
+    return new LimitPredicate<>(FIELD_LIMIT, limit);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
new file mode 100644
index 0000000..1e181e5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.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.query.project;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.query.project.ProjectQueryBuilder.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.AccountLimits;
+import com.google.gerrit.server.index.project.ProjectIndexCollection;
+import com.google.gerrit.server.index.project.ProjectIndexRewriter;
+import com.google.gerrit.server.index.project.ProjectSchemaDefinitions;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/**
+ * Query processor for the project index.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
+public class ProjectQueryProcessor extends QueryProcessor<ProjectData> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> userProvider;
+
+  static {
+    // It is assumed that basic rewrites do not touch visibleto predicates.
+    checkState(
+        !ProjectIsVisibleToPredicate.class.isAssignableFrom(IndexPredicate.class),
+        "ProjectQueryProcessor assumes visibleto is not used by the index rewriter.");
+  }
+
+  @Inject
+  protected ProjectQueryProcessor(
+      Provider<CurrentUser> userProvider,
+      AccountLimits.Factory limitsFactory,
+      MetricMaker metricMaker,
+      IndexConfig indexConfig,
+      ProjectIndexCollection indexes,
+      ProjectIndexRewriter rewriter,
+      PermissionBackend permissionBackend) {
+    super(
+        metricMaker,
+        ProjectSchemaDefinitions.INSTANCE,
+        indexConfig,
+        indexes,
+        rewriter,
+        FIELD_LIMIT,
+        () -> limitsFactory.create(userProvider.get()).getQueryLimit());
+    this.permissionBackend = permissionBackend;
+    this.userProvider = userProvider;
+  }
+
+  @Override
+  protected Predicate<ProjectData> enforceVisibility(Predicate<ProjectData> pred) {
+    return new AndSource<>(
+        pred, new ProjectIsVisibleToPredicate(permissionBackend, userProvider.get()), start);
+  }
+}
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
index 2015c14..7d12e58 100644
--- 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
@@ -14,17 +14,17 @@
 
 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.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
 import java.sql.Timestamp;
-import java.util.Comparator;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 
@@ -36,21 +36,45 @@
   }
 
   @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());
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (PreparedStatement groupUpdate =
+            prepareStatement(db, "UPDATE account_groups SET created_on = ? WHERE group_id = ?");
+        PreparedStatement addedOnRetrieval =
+            prepareStatement(
+                db,
+                "SELECT added_on FROM account_group_members_audit WHERE group_id = ?"
+                    + " ORDER BY added_on ASC")) {
+      List<AccountGroup.Id> accountGroups = getAllGroupIds(db);
+      for (AccountGroup.Id groupId : accountGroups) {
+        Optional<Timestamp> firstTimeMentioned = getFirstTimeMentioned(addedOnRetrieval, groupId);
+        Timestamp createdOn = firstTimeMentioned.orElseGet(AccountGroup::auditCreationInstantTs);
 
-      accountGroup.setCreatedOn(createdOn);
+        groupUpdate.setTimestamp(1, createdOn);
+        groupUpdate.setInt(2, groupId.get());
+        groupUpdate.executeUpdate();
+      }
     }
-    db.accountGroups().update(accountGroups);
+  }
+
+  private static Optional<Timestamp> getFirstTimeMentioned(
+      PreparedStatement addedOnRetrieval, AccountGroup.Id groupId) throws SQLException {
+    addedOnRetrieval.setInt(1, groupId.get());
+    try (ResultSet resultSet = addedOnRetrieval.executeQuery()) {
+      if (resultSet.first()) {
+        return Optional.of(resultSet.getTimestamp(1));
+      }
+    }
+    return Optional.empty();
+  }
+
+  private static List<AccountGroup.Id> getAllGroupIds(ReviewDb db) throws SQLException {
+    try (Statement stmt = newStatement(db);
+        ResultSet rs = stmt.executeQuery("SELECT group_id FROM account_groups")) {
+      List<AccountGroup.Id> groupIds = new ArrayList<>();
+      while (rs.next()) {
+        groupIds.add(new AccountGroup.Id(rs.getInt(1)));
+      }
+      return groupIds;
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_87.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_87.java
index 8ab949e..8a3ea08 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_87.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_87.java
@@ -20,11 +20,12 @@
 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.sql.Statement;
-import java.util.Collections;
 import java.util.HashSet;
+import java.util.Optional;
 import java.util.Set;
 
 public class Schema_87 extends SchemaVersion {
@@ -35,16 +36,37 @@
 
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    for (AccountGroup.Id id : scanSystemGroups(db)) {
-      AccountGroup group = db.accountGroups().get(id);
-      if (group != null && SystemGroupBackend.isSystemGroup(group.getGroupUUID())) {
-        db.accountGroups().delete(Collections.singleton(group));
-        db.accountGroupNames().deleteKeys(Collections.singleton(group.getNameKey()));
+    try (PreparedStatement uuidRetrieval =
+            prepareStatement(db, "SELECT group_uuid FROM account_groups WHERE group_id = ?");
+        PreparedStatement groupDeletion =
+            prepareStatement(db, "DELETE FROM account_groups WHERE group_id = ?");
+        PreparedStatement groupNameDeletion =
+            prepareStatement(db, "DELETE FROM account_group_names WHERE group_id = ?")) {
+      for (AccountGroup.Id id : scanSystemGroups(db)) {
+        Optional<AccountGroup.UUID> groupUuid = getUuid(uuidRetrieval, id);
+        if (groupUuid.filter(SystemGroupBackend::isSystemGroup).isPresent()) {
+          groupDeletion.setInt(1, id.get());
+          groupDeletion.executeUpdate();
+
+          groupNameDeletion.setInt(1, id.get());
+          groupNameDeletion.executeUpdate();
+        }
       }
     }
   }
 
-  private Set<AccountGroup.Id> scanSystemGroups(ReviewDb db) throws SQLException {
+  private static Optional<AccountGroup.UUID> getUuid(
+      PreparedStatement uuidRetrieval, AccountGroup.Id id) throws SQLException {
+    uuidRetrieval.setInt(1, id.get());
+    try (ResultSet uuidResults = uuidRetrieval.executeQuery()) {
+      if (uuidResults.first()) {
+        Optional.of(new AccountGroup.UUID(uuidResults.getString(1)));
+      }
+    }
+    return Optional.empty();
+  }
+
+  private static Set<AccountGroup.Id> scanSystemGroups(ReviewDb db) throws SQLException {
     try (Statement stmt = newStatement(db);
         ResultSet rs =
             stmt.executeQuery("SELECT group_id FROM account_groups WHERE group_type = 'SYSTEM'")) {
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 9bfcc61..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
@@ -78,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/prolog/gerrit_common.pl b/gerrit-server/src/main/prolog/gerrit_common.pl
index 4671e0d..8fd0657 100644
--- a/gerrit-server/src/main/prolog/gerrit_common.pl
+++ b/gerrit-server/src/main/prolog/gerrit_common.pl
@@ -279,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)) :-
@@ -306,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(_)).
@@ -321,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)) :-
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy
index 50c5fc3..623cfe26 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy
@@ -24,7 +24,7 @@
  * @param email
  * @param fromName
  */
-{template .Abandoned autoescape="strict" kind="text"}
+{template .Abandoned kind="text"}
   {$fromName} has abandoned this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
index c7d4699..75d940f 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
@@ -21,7 +21,7 @@
  * @param email
  * @param fromName
  */
-{template .AbandonedHtml autoescape="strict" kind="html"}
+{template .AbandonedHtml}
   <p>
     {$fromName} <strong>abandoned</strong> this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy
index aa2b27d..af99569 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy
@@ -21,7 +21,7 @@
  * adding a new SSH or GPG key to an account.
  * @param email
  */
-{template .AddKey autoescape="strict" kind="text"}
+{template .AddKey kind="text"}
   One or more new {$email.keyType} keys have been added to Gerrit Code Review at
   {sp}{$email.gerritHost}:
 
@@ -68,4 +68,4 @@
 
   This is a send-only email address.  Replies to this message will not be read
   or answered.
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
index 017fd6d..712abc7 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -19,7 +19,7 @@
 /**
  * @param email
  */
-{template .AddKeyHtml autoescape="strict" kind="html"}
+{template .AddKeyHtml}
   <p>
     One or more new {$email.keyType} keys have been added to Gerrit Code Review
     at {$email.gerritHost}:
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 37ac126..f1d201b 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
@@ -21,7 +21,7 @@
  * that will be appended to ALL emails related to changes.
  * @param email
  */
-{template .ChangeFooter autoescape="strict" kind="text"}
+{template .ChangeFooter kind="text"}
   --{sp}
   {\n}
 
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 00f21db..99263e8 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
@@ -20,7 +20,7 @@
  * @param change
  * @param email
  */
-{template .ChangeFooterHtml autoescape="strict" kind="html"}
+{template .ChangeFooterHtml}
   {if $email.changeUrl or $email.settingsUrl}
     <p>
       {if $email.changeUrl}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy
index 98de6e7..d8cffc4 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy
@@ -23,6 +23,6 @@
  * @param change
  * @param shortProjectName
  */
-{template .ChangeSubject autoescape="strict" kind="text"}
+{template .ChangeSubject kind="text"}
   Change in {$shortProjectName}[{$branch.shortName}]: {$change.shortSubject}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy
index 7bedc1c..7f3062c 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy
@@ -25,7 +25,7 @@
  * @param fromName
  * @param commentFiles
  */
-{template .Comment autoescape="strict" kind="text"}
+{template .Comment kind="text"}
   {$fromName} has posted comments on this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
index 73fdfba..3998438 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
@@ -21,5 +21,5 @@
  * that will be appended to emails related to a user submitting comments on
  * changes.
  */
-{template .CommentFooter autoescape="strict" kind="text"}
+{template .CommentFooter kind="text"}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
index 7bf28e7..033c1b1 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
@@ -16,5 +16,5 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .CommentFooterHtml autoescape="strict" kind="html"}
+{template .CommentFooterHtml}
 {/template}
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 870ad46..6917736 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
@@ -24,7 +24,7 @@
  * @param patchSet
  * @param patchSetCommentBlocks
  */
-{template .CommentHtml autoescape="strict" kind="html"}
+{template .CommentHtml}
   {let $commentHeaderStyle kind="css"}
     margin-bottom: 4px;
   {/let}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
index 888ee4b..fc1d60f 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
@@ -24,7 +24,7 @@
  * @param email
  * @param fromName
  */
-{template .DeleteReviewer autoescape="strict" kind="text"}
+{template .DeleteReviewer kind="text"}
   {$fromName} has removed{sp}
   {foreach $reviewerName in $email.reviewerNames}
     {if not isFirst($reviewerName)},{sp}{/if}
@@ -41,4 +41,4 @@
     {$coverLetter}
     {\n}
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
index 5faa411..f73e387 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
@@ -20,7 +20,7 @@
  * @param email
  * @param fromName
  */
-{template .DeleteReviewerHtml autoescape="strict" kind="html"}
+{template .DeleteReviewerHtml}
   <p>
     {$fromName}{sp}
     <strong>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy
index b249ded..724e90d 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy
@@ -23,7 +23,7 @@
  * @param coverLetter
  * @param fromName
  */
-{template .DeleteVote autoescape="strict" kind="text"}
+{template .DeleteVote kind="text"}
   {$fromName} has removed a vote on this change.{\n}
   {\n}
   Change subject: {$change.subject}{\n}
@@ -34,4 +34,4 @@
     {$coverLetter}
     {\n}
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
index 3d76ae2..cb8162d 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
@@ -21,7 +21,7 @@
  * @param email
  * @param fromName
  */
-{template .DeleteVoteHtml autoescape="strict" kind="html"}
+{template .DeleteVoteHtml}
   <p>
     {$fromName} <strong>removed a vote</strong> from this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
index 24db2fd..2b146ec 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
@@ -22,7 +22,7 @@
  * CommentFooter.
  * @param footers
  */
-{template .Footer autoescape="strict" kind="text"}
+{template .Footer kind="text"}
   {foreach $footer in $footers}
     {$footer}{\n}
   {/foreach}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
index 9f9c503..22929d1 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
@@ -19,7 +19,7 @@
 /**
  * @param footers
  */
-{template .FooterHtml autoescape="strict" kind="html"}
+{template .FooterHtml}
   {\n}
   {\n}
   {foreach $footer in $footers}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
index fdc3fee..4710d8c 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
@@ -16,5 +16,5 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .HeaderHtml autoescape="strict" kind="html"}
+{template .HeaderHtml}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy
index d483264..40924e6 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy
@@ -24,7 +24,7 @@
  * @param email
  * @param fromName
  */
-{template .Merged autoescape="strict" kind="text"}
+{template .Merged kind="text"}
   {$fromName} has submitted this change and it was merged.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
@@ -39,4 +39,4 @@
     {$email.unifiedDiff}
     {\n}
   {/if}
-{/template}
\ No newline at end of file
+{/template}
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 927601b..b11c5e5 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
@@ -21,7 +21,7 @@
  * @param email
  * @param fromName
  */
-{template .MergedHtml autoescape="strict" kind="html"}
+{template .MergedHtml}
   <p>
     {$fromName} <strong>merged</strong> this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy
index 9f7429f..ca24d19 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy
@@ -25,7 +25,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .NewChange autoescape="strict" kind="text"}
+{template .NewChange kind="text"}
   {if $email.reviewerNames}
     Hello{sp}
     {foreach $reviewerName in $email.reviewerNames}
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 8026666..16b0df4 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
@@ -24,7 +24,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .NewChangeHtml autoescape="strict" kind="html"}
+{template .NewChangeHtml}
   <p>
     {if $email.reviewerNames}
       {$fromName} would like{sp}
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 b26535b..5840223 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 autoescape="strict" kind="html"}
+{template .ViewChangeButton}
   <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 autoescape="strict" kind="html"}
+{template .Pre}
   {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 autoescape="strict" kind="html"}
+{template .WikiFormat}
   {let $blockquoteStyle kind="css"}
     border-left: 1px solid #aaa;
     margin: 10px 0;
@@ -90,7 +90,7 @@
 /**
  * @param diffLines
  */
-{template .UnifiedDiff autoescape="strict" kind="html"}
+{template .UnifiedDiff}
   {let $addStyle kind="css"}
     color: hsl(120, 100%, 40%);
   {/let}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
index 2b30ae6..2886cc0 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
@@ -21,7 +21,7 @@
  * related to registering new email accounts.
  * @param email
  */
-{template .RegisterNewEmail autoescape="strict" kind="text"}
+{template .RegisterNewEmail kind="text"}
   Welcome to Gerrit Code Review at {$email.gerritHost}.{\n}
 
   {\n}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
index e41bdda..124cdf3 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
@@ -26,7 +26,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .ReplacePatchSet autoescape="strict" kind="text"}
+{template .ReplacePatchSet kind="text"}
   {if $email.reviewerNames and $fromEmail == $change.ownerEmail}
     Hello{sp}
     {foreach $reviewerName in $email.reviewerNames}
@@ -60,4 +60,4 @@
         {$patchSet.refName}
     {\n}
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
index 05c60a1..e618bef 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -24,7 +24,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .ReplacePatchSetHtml autoescape="strict" kind="html"}
+{template .ReplacePatchSetHtml}
   <p>
     {$fromName} <strong>uploaded patch set #{$patchSet.patchSetId}</strong>{sp}
     to{sp}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy
index 14ae0f3..4fc6d8c 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy
@@ -24,7 +24,7 @@
  * @param email
  * @param fromName
  */
-{template .Restored autoescape="strict" kind="text"}
+{template .Restored kind="text"}
   {$fromName} has restored this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
@@ -36,4 +36,4 @@
     {$coverLetter}
     {\n}
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
index ea4f615..bb856ac 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
@@ -20,7 +20,7 @@
  * @param email
  * @param fromName
  */
-{template .RestoredHtml autoescape="strict" kind="html"}
+{template .RestoredHtml}
   <p>
     {$fromName} <strong>restored</strong> this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy
index 7f74df9..09e32ff 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy
@@ -24,7 +24,7 @@
  * @param email
  * @param fromName
  */
-{template .Reverted autoescape="strict" kind="text"}
+{template .Reverted kind="text"}
   {$fromName} has reverted this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
index d6407e7..63ad6f0 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
@@ -20,7 +20,7 @@
  * @param email
  * @param fromName
  */
-{template .RevertedHtml autoescape="strict" kind="html"}
+{template .RevertedHtml}
   <p>
     {$fromName} <strong>reverted</strong> this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssignee.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssignee.soy
index ca4f267..98290e9 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssignee.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssignee.soy
@@ -25,7 +25,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .SetAssignee autoescape="strict" kind="text"}
+{template .SetAssignee kind="text"}
   Hello{sp}
   {$email.assigneeName},
 
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 31cfbd6..dbd3fae 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
@@ -23,7 +23,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .SetAssigneeHtml autoescape="strict" kind="html"}
+{template .SetAssigneeHtml}
   <p>
     {$fromName} has <strong>assigned</strong> a change to{sp}
     {$email.assigneeName}.{sp}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/ScheduleConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/ScheduleConfigTest.java
index e6f36b9..0423a53 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/ScheduleConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/ScheduleConfigTest.java
@@ -20,15 +20,19 @@
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static org.junit.Assert.assertEquals;
 
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
-import org.joda.time.DateTime;
 import org.junit.Test;
 
 public class ScheduleConfigTest {
 
   // Friday June 13, 2014 10:00 UTC
-  private static final DateTime NOW = DateTime.parse("2014-06-13T10:00:00-00:00");
+  private static final ZonedDateTime NOW =
+      LocalDateTime.of(2014, Month.JUNE, 13, 10, 0, 0).atOffset(ZoneOffset.UTC).toZonedDateTime();
 
   @Test
   public void initialDelay() throws Exception {
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
index 801b2b0..574c795 100644
--- 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
@@ -17,10 +17,9 @@
 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.FailureMetadata;
 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;
@@ -30,26 +29,14 @@
 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);
+    return assertAbout(ChangeFileContentModificationSubject::new).that(modification);
   }
 
   private ChangeFileContentModificationSubject(
-      FailureStrategy failureStrategy, ChangeFileContentModification modification) {
-    super(failureStrategy, modification);
+      FailureMetadata failureMetadata, ChangeFileContentModification modification) {
+    super(failureMetadata, modification);
   }
 
   public StringSubject filePath() {
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
index ac4ebb8..59ee2b7 100644
--- 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
@@ -16,26 +16,15 @@
 
 import static com.google.common.truth.Truth.assertAbout;
 
-import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.FailureMetadata;
 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);
+    return assertAbout(TreeModificationSubject::new).that(treeModification);
   }
 
   public static ListSubject<TreeModificationSubject, TreeModification> assertThatList(
@@ -45,8 +34,8 @@
   }
 
   private TreeModificationSubject(
-      FailureStrategy failureStrategy, TreeModification treeModification) {
-    super(failureStrategy, treeModification);
+      FailureMetadata failureMetadata, TreeModification treeModification) {
+    super(failureMetadata, treeModification);
   }
 
   public ChangeFileContentModificationSubject asChangeFileContentModification() {
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 75b00fd..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
@@ -150,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/*");
@@ -158,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(notes, list(cr, v)));
+    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
   @Test
@@ -177,10 +176,10 @@
   }
 
   @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(notes, list(cr, v)));
+    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
   @Test
@@ -191,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(notes, 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/index/change/FakeQueryBuilder.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index a194336..b525504 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
@@ -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, indexes, null, null, null, null, null, null, null,
-            null));
+            null, null, null, null, null, indexes, null, null, null, null, null, null, null, null));
   }
 
   @Operator
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java
index 19ad8bb..7309437 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java
@@ -20,9 +20,9 @@
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.server.mail.Address;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
-import org.joda.time.DateTime;
 import org.junit.Ignore;
 
 @Ignore
@@ -85,7 +85,7 @@
     MailMessage.Builder b = MailMessage.builder();
     b.id("id");
     b.from(new Address("Foo Bar", "foo@bar.com"));
-    b.dateReceived(new DateTime());
+    b.dateReceived(Instant.now());
     b.subject("");
     return b;
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java
index 84bae96..dc25939 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java
@@ -20,8 +20,10 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.MetadataName;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Test;
 
 public class MetadataParserTest {
@@ -31,7 +33,7 @@
     // email headers of the message.
     MailMessage.Builder b = MailMessage.builder();
     b.id("");
-    b.dateReceived(new DateTime());
+    b.dateReceived(Instant.now());
     b.subject("");
 
     b.addAdditionalHeader(toHeaderWithDelimiter(MetadataName.CHANGE_NUMBER) + "123");
@@ -48,8 +50,11 @@
     assertThat(meta.changeNumber).isEqualTo(123);
     assertThat(meta.patchSet).isEqualTo(1);
     assertThat(meta.messageType).isEqualTo("comment");
-    assertThat(meta.timestamp.getTime())
-        .isEqualTo(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC).getMillis());
+    assertThat(meta.timestamp.toInstant())
+        .isEqualTo(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
   }
 
   @Test
@@ -58,7 +63,7 @@
     // the text body of the message.
     MailMessage.Builder b = MailMessage.builder();
     b.id("");
-    b.dateReceived(new DateTime());
+    b.dateReceived(Instant.now());
     b.subject("");
 
     StringBuilder stringBuilder = new StringBuilder();
@@ -77,8 +82,11 @@
     assertThat(meta.changeNumber).isEqualTo(123);
     assertThat(meta.patchSet).isEqualTo(1);
     assertThat(meta.messageType).isEqualTo("comment");
-    assertThat(meta.timestamp.getTime())
-        .isEqualTo(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC).getMillis());
+    assertThat(meta.timestamp.toInstant())
+        .isEqualTo(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
   }
 
   @Test
@@ -87,7 +95,7 @@
     // the HTML body of the message.
     MailMessage.Builder b = MailMessage.builder();
     b.id("");
-    b.dateReceived(new DateTime());
+    b.dateReceived(Instant.now());
     b.subject("");
 
     StringBuilder stringBuilder = new StringBuilder();
@@ -111,7 +119,10 @@
     assertThat(meta.changeNumber).isEqualTo(123);
     assertThat(meta.patchSet).isEqualTo(1);
     assertThat(meta.messageType).isEqualTo("comment");
-    assertThat(meta.timestamp.getTime())
-        .isEqualTo(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC).getMillis());
+    assertThat(meta.timestamp.toInstant())
+        .isEqualTo(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/RawMailParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/RawMailParserTest.java
index 4efa817..001d12d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/RawMailParserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/RawMailParserTest.java
@@ -65,7 +65,7 @@
     assertThat(have.to()).isEqualTo(want.to());
     assertThat(have.from()).isEqualTo(want.from());
     assertThat(have.cc()).isEqualTo(want.cc());
-    assertThat(have.dateReceived().getMillis()).isEqualTo(want.dateReceived().getMillis());
+    assertThat(have.dateReceived()).isEqualTo(want.dateReceived());
     assertThat(have.additionalHeaders()).isEqualTo(want.additionalHeaders());
     assertThat(have.subject()).isEqualTo(want.subject());
     assertThat(have.textContent()).isEqualTo(want.textContent());
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 be8d882..eb4d180 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
@@ -16,8 +16,9 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.receive.MailMessage;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Ignore;
 
 /**
@@ -82,7 +83,10 @@
         .htmlContent("<div dir=\"ltr\">Contains unwanted attachment</div>")
         .subject("Test Subject")
         .addAdditionalHeader("MIME-Version: 1.0")
-        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
     return expect.build();
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
index affa3bd..91dc6f1 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
@@ -16,8 +16,9 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.receive.MailMessage;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Ignore;
 
 /** Tests parsing a Base64 encoded subject. */
@@ -58,7 +59,10 @@
         .addTo(new Address("ekempin", "ekempin@google.com"))
         .textContent(textContent)
         .subject("\uD83D\uDE1B test")
-        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
     return expect.build();
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
index 487e9dd..756581f 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
@@ -16,8 +16,9 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.receive.MailMessage;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Ignore;
 
 /** Tests a message containing mime/alternative (text + html) content. */
@@ -98,7 +99,10 @@
         .htmlContent(unencodedHtmlContent)
         .subject("Change in gerrit[master]: Implement receiver class structure and bindings")
         .addAdditionalHeader("MIME-Version: 1.0")
-        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
     return expect.build();
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
index 9f2af0d..3fafd4b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
@@ -15,8 +15,9 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.receive.MailMessage;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Ignore;
 
 /** Tests that non-UTF8 encodings are handled correctly. */
@@ -62,7 +63,10 @@
         .addTo(new Address("ekempin", "ekempin@google.com"))
         .textContent(textContent)
         .subject("\uD83D\uDE1B test")
-        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
     return expect.build();
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
index 2c17859..2dc48b5 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
@@ -16,8 +16,9 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.receive.MailMessage;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Ignore;
 
 /** Tests parsing a quoted printable encoded subject */
@@ -59,7 +60,10 @@
         .addTo(new Address("ekempin", "ekempin@google.com"))
         .textContent(textContent)
         .subject("âme vulgaire")
-        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
     return expect.build();
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
index ce833d5..aa5b78a 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
@@ -16,8 +16,9 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.receive.MailMessage;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Ignore;
 
 /** Tests parsing a simple text message with different headers. */
@@ -124,7 +125,10 @@
         .addCc(new Address("Patrick Hiesel", "hiesel@google.com"))
         .textContent(textContent)
         .subject("Change in gerrit[master]: (Re)enable voting buttons for merged changes")
-        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC))
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant())
         .addAdditionalHeader(
             "Authentication-Results: mx.google.com; dkim=pass header.i=@google.com;")
         .addAdditionalHeader(
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..33e1005 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
@@ -46,13 +46,14 @@
 import com.google.gwtorm.protobuf.CodecFactory;
 import com.google.gwtorm.protobuf.ProtobufCodec;
 import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.Month;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.TimeZone;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -67,6 +68,7 @@
       CodecFactory.encoder(PatchSetApproval.class);
   private static final ProtobufCodec<PatchLineComment> PATCH_LINE_COMMENT_CODEC =
       CodecFactory.encoder(PatchLineComment.class);
+  private static final String TIMEZONE_ID = "US/Eastern";
 
   private String systemTimeZoneProperty;
   private TimeZone systemTimeZone;
@@ -76,10 +78,9 @@
 
   @Before
   public void setUp() {
-    String tz = "US/Eastern";
-    systemTimeZoneProperty = System.setProperty("user.timezone", tz);
+    systemTimeZoneProperty = System.setProperty("user.timezone", TIMEZONE_ID);
     systemTimeZone = TimeZone.getDefault();
-    TimeZone.setDefault(TimeZone.getTimeZone(tz));
+    TimeZone.setDefault(TimeZone.getTimeZone(TIMEZONE_ID));
     long maxMs = ChangeRebuilderImpl.MAX_WINDOW_MS;
     assertThat(maxMs).isGreaterThan(1000L);
     TestTimeUtil.resetWithClockStep(maxMs * 2, MILLISECONDS);
@@ -1517,8 +1518,11 @@
     PatchSetApproval a2 = clone(a1);
     a2.setGranted(
         new Timestamp(
-            new DateTime(1900, 1, 1, 0, 0, 0, DateTimeZone.forTimeZone(TimeZone.getDefault()))
-                .getMillis()));
+            LocalDate.of(1900, Month.JANUARY, 1)
+                .atStartOfDay()
+                .atZone(ZoneId.of(TIMEZONE_ID))
+                .toInstant()
+                .toEpochMilli()));
 
     // Both are ReviewDb, exact match is required.
     ChangeBundle b1 =
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 f7ce73f..049b86b 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
@@ -35,7 +35,6 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
@@ -133,7 +132,7 @@
   }
 
   private void assertCanUpload(ProjectControl u) {
-    assertThat(u.canPushToAtLeastOneRef()).named("can upload").isEqualTo(Capable.OK);
+    assertThat(u.canPushToAtLeastOneRef()).named("can upload").isTrue();
   }
 
   private void assertCreateChange(String ref, ProjectControl u) {
@@ -142,7 +141,7 @@
   }
 
   private void assertCannotUpload(ProjectControl u) {
-    assertThat(u.canPushToAtLeastOneRef()).named("cannot upload").isNotEqualTo(Capable.OK);
+    assertThat(u.canPushToAtLeastOneRef()).named("cannot upload").isFalse();
   }
 
   private void assertCannotCreateChange(String ref, ProjectControl u) {
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/change/AbstractQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 1e722fc..8c83fbc 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
@@ -38,6 +38,8 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
@@ -192,6 +194,17 @@
 
   private String systemTimeZone;
 
+  // These queries must be kept in sync with PolyGerrit:
+  // polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+
+  protected static String DASHBOARD_WORK_IN_PROGRESS_QUERY = "is:open owner:${user} is:wip";
+  protected static String DASHBOARD_OUTGOING_QUERY = "is:open owner:${user} -is:wip -is:ignored";
+  protected static String DASHBOARD_INCOMING_QUERY =
+      "is:open -owner:${user} -is:wip -is:ignored (reviewer:${user} OR assignee:${user})";
+  protected static String DASHBOARD_RECENTLY_CLOSED_QUERY =
+      "is:closed -is:ignored (-is:wip OR owner:self) "
+          + "(owner:${user} OR reviewer:${user} OR assignee:${user})";
+
   protected abstract Injector createInjector();
 
   @Before
@@ -1232,7 +1245,7 @@
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
-    long startMs = TestTimeUtil.START.getMillis();
+    long startMs = TestTimeUtil.START.toEpochMilli();
     Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
 
@@ -1259,7 +1272,7 @@
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
-    long startMs = TestTimeUtil.START.getMillis();
+    long startMs = TestTimeUtil.START.toEpochMilli();
     Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
@@ -1281,7 +1294,7 @@
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
-    long startMs = TestTimeUtil.START.getMillis();
+    long startMs = TestTimeUtil.START.toEpochMilli();
     Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
@@ -2187,6 +2200,307 @@
         "revertof:" + changeToRevert._number, new Change.Id(changeThatReverts._number));
   }
 
+  /** Change builder for helping in tests for dashboard sections. */
+  protected class DashboardChangeState {
+    private final Account.Id ownerId;
+    private final List<Account.Id> reviewedBy;
+    private final List<Account.Id> ignoredBy;
+    private boolean wip;
+    private boolean abandoned;
+    @Nullable private Account.Id mergedBy;
+    @Nullable private Account.Id assigneeId;
+
+    @Nullable Change.Id id;
+
+    DashboardChangeState(Account.Id ownerId) {
+      this.ownerId = ownerId;
+      reviewedBy = new ArrayList<>();
+      ignoredBy = new ArrayList<>();
+    }
+
+    DashboardChangeState assignTo(Account.Id assigneeId) {
+      this.assigneeId = assigneeId;
+      return this;
+    }
+
+    DashboardChangeState wip() {
+      wip = true;
+      return this;
+    }
+
+    DashboardChangeState abandon() {
+      abandoned = true;
+      return this;
+    }
+
+    DashboardChangeState mergeBy(Account.Id mergedBy) {
+      this.mergedBy = mergedBy;
+      return this;
+    }
+
+    DashboardChangeState ignoreBy(Account.Id ignorerId) {
+      ignoredBy.add(ignorerId);
+      return this;
+    }
+
+    DashboardChangeState addReviewer(Account.Id reviewerId) {
+      reviewedBy.add(reviewerId);
+      return this;
+    }
+
+    DashboardChangeState create(TestRepository<Repo> repo) throws Exception {
+      requestContext.setContext(newRequestContext(ownerId));
+      Change change = insert(repo, newChange(repo), ownerId);
+      id = change.getId();
+      ChangeApi cApi = gApi.changes().id(change.getChangeId());
+      if (assigneeId != null) {
+        AssigneeInput in = new AssigneeInput();
+        in.assignee = "" + assigneeId;
+        cApi.setAssignee(in);
+      }
+      if (wip) {
+        cApi.setWorkInProgress();
+      }
+      if (abandoned) {
+        cApi.abandon();
+      }
+      for (Account.Id reviewerId : reviewedBy) {
+        cApi.addReviewer("" + reviewerId);
+      }
+      for (Account.Id ignorerId : ignoredBy) {
+        requestContext.setContext(newRequestContext(ignorerId));
+        StarsInput in = new StarsInput(new HashSet<>(Arrays.asList("ignore")));
+        gApi.accounts().self().setStars("" + id, in);
+      }
+      if (mergedBy != null) {
+        requestContext.setContext(newRequestContext(mergedBy));
+        cApi = gApi.changes().id(change.getChangeId());
+        cApi.current().review(ReviewInput.approve());
+        cApi.current().submit();
+      }
+      requestContext.setContext(newRequestContext(user.getAccountId()));
+      return this;
+    }
+  }
+
+  protected List<ChangeInfo> assertDashboardQuery(
+      String viewedUser, String query, DashboardChangeState... expected) throws Exception {
+    Change.Id[] ids = new Change.Id[expected.length];
+    for (int i = 0; i < expected.length; i++) {
+      ids[i] = expected[i].id;
+    }
+    return assertQueryByIds(query.replaceAll("\\$\\{user}", viewedUser), ids);
+  }
+
+  @Test
+  public void dashboardWorkInProgressReviews() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    DashboardChangeState ownedOpenWip =
+        new DashboardChangeState(user.getAccountId()).wip().create(repo);
+
+    // Create changes that should not be returned by query.
+    new DashboardChangeState(user.getAccountId()).wip().abandon().create(repo);
+    new DashboardChangeState(user.getAccountId()).mergeBy(user.getAccountId()).create(repo);
+    new DashboardChangeState(createAccount("other")).wip().create(repo);
+
+    assertDashboardQuery("self", DASHBOARD_WORK_IN_PROGRESS_QUERY, ownedOpenWip);
+  }
+
+  @Test
+  public void dashboardOutgoingReviews() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Account.Id otherAccountId = createAccount("other");
+    DashboardChangeState ownedOpenReviewable =
+        new DashboardChangeState(user.getAccountId()).create(repo);
+    DashboardChangeState ownedOpenReviewableIgnoredByOther =
+        new DashboardChangeState(user.getAccountId()).ignoreBy(otherAccountId).create(repo);
+
+    // Create changes that should not be returned by any queries in this test.
+    new DashboardChangeState(user.getAccountId()).wip().create(repo);
+    new DashboardChangeState(otherAccountId).create(repo);
+
+    // Viewing one's own dashboard.
+    assertDashboardQuery(
+        "self", DASHBOARD_OUTGOING_QUERY, ownedOpenReviewableIgnoredByOther, ownedOpenReviewable);
+
+    // Viewing another user's dashboard.
+    requestContext.setContext(newRequestContext(otherAccountId));
+    assertDashboardQuery(user.getUserName(), DASHBOARD_OUTGOING_QUERY, ownedOpenReviewable);
+  }
+
+  @Test
+  public void dashboardIncomingReviews() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Account.Id otherAccountId = createAccount("other");
+    DashboardChangeState reviewingReviewable =
+        new DashboardChangeState(otherAccountId).addReviewer(user.getAccountId()).create(repo);
+    DashboardChangeState reviewingReviewableIgnoredByReviewer =
+        new DashboardChangeState(otherAccountId)
+            .addReviewer(user.getAccountId())
+            .ignoreBy(user.getAccountId())
+            .create(repo);
+    DashboardChangeState assignedReviewable =
+        new DashboardChangeState(otherAccountId).assignTo(user.getAccountId()).create(repo);
+    DashboardChangeState assignedReviewableIgnoredByAssignee =
+        new DashboardChangeState(otherAccountId)
+            .assignTo(user.getAccountId())
+            .ignoreBy(user.getAccountId())
+            .create(repo);
+
+    // Create changes that should not be returned by any queries in this test.
+    new DashboardChangeState(otherAccountId).wip().addReviewer(user.getAccountId()).create(repo);
+    new DashboardChangeState(otherAccountId).wip().assignTo(user.getAccountId()).create(repo);
+    new DashboardChangeState(otherAccountId).addReviewer(otherAccountId).create(repo);
+    new DashboardChangeState(otherAccountId)
+        .addReviewer(user.getAccountId())
+        .mergeBy(user.getAccountId())
+        .create(repo);
+
+    // Viewing one's own dashboard.
+    assertDashboardQuery("self", DASHBOARD_INCOMING_QUERY, assignedReviewable, reviewingReviewable);
+
+    // Viewing another user's dashboard.
+    requestContext.setContext(newRequestContext(otherAccountId));
+    assertDashboardQuery(
+        user.getUserName(),
+        DASHBOARD_INCOMING_QUERY,
+        assignedReviewableIgnoredByAssignee,
+        assignedReviewable,
+        reviewingReviewableIgnoredByReviewer,
+        reviewingReviewable);
+  }
+
+  @Test
+  public void dashboardRecentlyClosedReviews() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Account.Id otherAccountId = createAccount("other");
+    DashboardChangeState mergedOwned =
+        new DashboardChangeState(user.getAccountId()).mergeBy(user.getAccountId()).create(repo);
+    DashboardChangeState mergedOwnedIgnoredByOther =
+        new DashboardChangeState(user.getAccountId())
+            .ignoreBy(otherAccountId)
+            .mergeBy(user.getAccountId())
+            .create(repo);
+    DashboardChangeState mergedReviewing =
+        new DashboardChangeState(otherAccountId)
+            .addReviewer(user.getAccountId())
+            .mergeBy(user.getAccountId())
+            .create(repo);
+    DashboardChangeState mergedReviewingIgnoredByUser =
+        new DashboardChangeState(otherAccountId)
+            .addReviewer(user.getAccountId())
+            .ignoreBy(user.getAccountId())
+            .mergeBy(user.getAccountId())
+            .create(repo);
+    DashboardChangeState mergedAssigned =
+        new DashboardChangeState(otherAccountId)
+            .assignTo(user.getAccountId())
+            .mergeBy(user.getAccountId())
+            .create(repo);
+    DashboardChangeState mergedAssignedIgnoredByUser =
+        new DashboardChangeState(otherAccountId)
+            .assignTo(user.getAccountId())
+            .ignoreBy(user.getAccountId())
+            .mergeBy(user.getAccountId())
+            .create(repo);
+    DashboardChangeState abandonedOwned =
+        new DashboardChangeState(user.getAccountId()).abandon().create(repo);
+    DashboardChangeState abandonedOwnedIgnoredByOther =
+        new DashboardChangeState(user.getAccountId())
+            .ignoreBy(otherAccountId)
+            .abandon()
+            .create(repo);
+    DashboardChangeState abandonedOwnedWip =
+        new DashboardChangeState(user.getAccountId()).wip().abandon().create(repo);
+    DashboardChangeState abandonedOwnedWipIgnoredByOther =
+        new DashboardChangeState(user.getAccountId())
+            .ignoreBy(otherAccountId)
+            .wip()
+            .abandon()
+            .create(repo);
+    DashboardChangeState abandonedReviewing =
+        new DashboardChangeState(otherAccountId)
+            .addReviewer(user.getAccountId())
+            .abandon()
+            .create(repo);
+    DashboardChangeState abandonedReviewingIgnoredByUser =
+        new DashboardChangeState(otherAccountId)
+            .addReviewer(user.getAccountId())
+            .ignoreBy(user.getAccountId())
+            .abandon()
+            .create(repo);
+    DashboardChangeState abandonedAssigned =
+        new DashboardChangeState(otherAccountId)
+            .assignTo(user.getAccountId())
+            .abandon()
+            .create(repo);
+    DashboardChangeState abandonedAssignedIgnoredByUser =
+        new DashboardChangeState(otherAccountId)
+            .assignTo(user.getAccountId())
+            .ignoreBy(user.getAccountId())
+            .abandon()
+            .create(repo);
+    DashboardChangeState abandonedAssignedWip =
+        new DashboardChangeState(otherAccountId)
+            .assignTo(user.getAccountId())
+            .wip()
+            .abandon()
+            .create(repo);
+    DashboardChangeState abandonedAssignedWipIgnoredByUser =
+        new DashboardChangeState(otherAccountId)
+            .assignTo(user.getAccountId())
+            .ignoreBy(user.getAccountId())
+            .wip()
+            .abandon()
+            .create(repo);
+
+    // Create changes that should not be returned by any queries in this test.
+    new DashboardChangeState(otherAccountId)
+        .addReviewer(user.getAccountId())
+        .wip()
+        .abandon()
+        .create(repo);
+    new DashboardChangeState(otherAccountId)
+        .addReviewer(user.getAccountId())
+        .ignoreBy(user.getAccountId())
+        .wip()
+        .abandon()
+        .create(repo);
+
+    // Viewing one's own dashboard.
+    assertDashboardQuery(
+        "self",
+        DASHBOARD_RECENTLY_CLOSED_QUERY,
+        abandonedAssigned,
+        abandonedReviewing,
+        abandonedOwnedWipIgnoredByOther,
+        abandonedOwnedWip,
+        abandonedOwnedIgnoredByOther,
+        abandonedOwned,
+        mergedAssigned,
+        mergedReviewing,
+        mergedOwnedIgnoredByOther,
+        mergedOwned);
+
+    // Viewing another user's dashboard.
+    requestContext.setContext(newRequestContext(otherAccountId));
+    assertDashboardQuery(
+        user.getUserName(),
+        DASHBOARD_RECENTLY_CLOSED_QUERY,
+        abandonedAssignedWipIgnoredByUser,
+        abandonedAssignedWip,
+        abandonedAssignedIgnoredByUser,
+        abandonedAssigned,
+        abandonedReviewingIgnoredByUser,
+        abandonedReviewing,
+        abandonedOwned,
+        mergedAssignedIgnoredByUser,
+        mergedAssigned,
+        mergedReviewingIgnoredByUser,
+        mergedReviewing,
+        mergedOwned);
+  }
+
   protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
     return newChange(repo, null, null, null, null, false);
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
new file mode 100644
index 0000000..8804b96
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -0,0 +1,372 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.CharMatcher;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.api.projects.Projects.QueryRequest;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.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.query.account.InternalAccountQuery;
+import com.google.gerrit.server.schema.SchemaCreator;
+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;
+import com.google.gerrit.testutil.GerritServerTests;
+import com.google.gerrit.testutil.InMemoryDatabase;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+
+@Ignore
+public abstract class AbstractQueryProjectsTest extends GerritServerTests {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setInt("index", null, "maxPages", 10);
+    return cfg;
+  }
+
+  @Inject protected Accounts accounts;
+
+  @Inject protected AccountsUpdate.Server accountsUpdate;
+
+  @Inject protected AccountCache accountCache;
+
+  @Inject protected AccountManager accountManager;
+
+  @Inject protected GerritApi gApi;
+
+  @Inject protected IdentifiedUser.GenericFactory userFactory;
+
+  @Inject private Provider<AnonymousUser> anonymousUser;
+
+  @Inject protected InMemoryDatabase schemaFactory;
+
+  @Inject protected SchemaCreator schemaCreator;
+
+  @Inject protected ThreadLocalRequestContext requestContext;
+
+  @Inject protected OneOffRequestContext oneOffRequestContext;
+
+  @Inject protected InternalAccountQuery internalAccountQuery;
+
+  @Inject protected AllProjectsName allProjects;
+
+  protected LifecycleManager lifecycle;
+  protected Injector injector;
+  protected ReviewDb db;
+  protected AccountInfo currentUserInfo;
+  protected CurrentUser user;
+
+  protected abstract Injector createInjector();
+
+  @Before
+  public void setUpInjector() throws Exception {
+    lifecycle = new LifecycleManager();
+    injector = createInjector();
+    lifecycle.add(injector);
+    injector.injectMembers(this);
+    lifecycle.start();
+    setUpDatabase();
+  }
+
+  protected void setUpDatabase() throws Exception {
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+
+    Account.Id userId = createAccount("user", "User", "user@example.com", true);
+    user = userFactory.create(userId);
+    requestContext.setContext(newRequestContext(userId));
+    currentUserInfo = gApi.accounts().id(userId.get()).get();
+  }
+
+  protected RequestContext newRequestContext(Account.Id requestUserId) {
+    final CurrentUser requestUser = userFactory.create(requestUserId);
+    return new RequestContext() {
+      @Override
+      public CurrentUser getUser() {
+        return requestUser;
+      }
+
+      @Override
+      public Provider<ReviewDb> getReviewDbProvider() {
+        return Providers.of(db);
+      }
+    };
+  }
+
+  protected void setAnonymous() {
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return anonymousUser.get();
+          }
+
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
+  }
+
+  @After
+  public void tearDownInjector() {
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  public void byName() throws Exception {
+    assertQuery("name:project");
+    assertQuery("name:non-existing");
+
+    ProjectInfo project = createProject(name("project"));
+
+    assertQuery("name:" + project.name, project);
+
+    // only exact match
+    ProjectInfo projectWithHyphen = createProject(name("project-with-hyphen"));
+    createProject(name("project-no-match-with-hyphen"));
+    assertQuery("name:" + projectWithHyphen.name, projectWithHyphen);
+  }
+
+  @Test
+  public void byInname() throws Exception {
+    String namePart = getSanitizedMethodName();
+    namePart = CharMatcher.is('_').removeFrom(namePart);
+
+    ProjectInfo project1 = createProject(name("project-" + namePart));
+    ProjectInfo project2 = createProject(name("project-" + namePart + "-2"));
+    ProjectInfo project3 = createProject(name("project-" + namePart + "3"));
+
+    assertQuery("inname:" + namePart, project1, project2, project3);
+    assertQuery("inname:" + namePart.toUpperCase(Locale.US), project1, project2, project3);
+    assertQuery("inname:" + namePart.toLowerCase(Locale.US), project1, project2, project3);
+  }
+
+  @Test
+  public void byDescription() throws Exception {
+    ProjectInfo project1 =
+        createProjectWithDescription(name("project1"), "This is a test project.");
+    ProjectInfo project2 = createProjectWithDescription(name("project2"), "ANOTHER TEST PROJECT.");
+    createProjectWithDescription(name("project3"), "Maintainers of project foo.");
+    assertQuery("description:test", project1, project2);
+
+    assertQuery("description:non-existing");
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("description operator requires a value");
+    assertQuery("description:\"\"");
+  }
+
+  @Test
+  public void byDefaultField() throws Exception {
+    ProjectInfo project1 = createProject(name("foo-project"));
+    ProjectInfo project2 = createProject(name("project2"));
+    ProjectInfo project3 =
+        createProjectWithDescription(
+            name("project3"),
+            "decription that contains foo and the UUID of project2: " + project2.id);
+
+    assertQuery("non-existing");
+    assertQuery("foo", project1, project3);
+    assertQuery(project2.id, project2, project3);
+  }
+
+  @Test
+  public void withLimit() throws Exception {
+    ProjectInfo project1 = createProject(name("project1"));
+    ProjectInfo project2 = createProject(name("project2"));
+    ProjectInfo project3 = createProject(name("project3"));
+
+    String query =
+        "name:" + project1.name + " OR name:" + project2.name + " OR name:" + project3.name;
+    List<ProjectInfo> result = assertQuery(query, project1, project2, project3);
+
+    result = assertQuery(newQuery(query).withLimit(2), result.subList(0, 2));
+  }
+
+  @Test
+  public void withStart() throws Exception {
+    ProjectInfo project1 = createProject(name("project1"));
+    ProjectInfo project2 = createProject(name("project2"));
+    ProjectInfo project3 = createProject(name("project3"));
+
+    String query =
+        "name:" + project1.name + " OR name:" + project2.name + " OR name:" + project3.name;
+    List<ProjectInfo> result = assertQuery(query, project1, project2, project3);
+
+    assertQuery(newQuery(query).withStart(1), result.subList(1, 3));
+  }
+
+  @Test
+  public void asAnonymous() throws Exception {
+    ProjectInfo project = createProject(name("project"));
+
+    setAnonymous();
+    assertQuery("name:" + project.name);
+  }
+
+  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 ProjectInfo createProject(String name) throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = name;
+    return gApi.projects().create(in).get();
+  }
+
+  protected ProjectInfo createProjectWithDescription(String name, String description)
+      throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = name;
+    in.description = description;
+    return gApi.projects().create(in).get();
+  }
+
+  protected ProjectInfo getProject(Project.NameKey nameKey) throws Exception {
+    return gApi.projects().name(nameKey.get()).get();
+  }
+
+  protected List<ProjectInfo> assertQuery(Object query, ProjectInfo... projects) throws Exception {
+    return assertQuery(newQuery(query), projects);
+  }
+
+  protected List<ProjectInfo> assertQuery(QueryRequest query, ProjectInfo... projects)
+      throws Exception {
+    return assertQuery(query, Arrays.asList(projects));
+  }
+
+  protected List<ProjectInfo> assertQuery(QueryRequest query, List<ProjectInfo> projects)
+      throws Exception {
+    List<ProjectInfo> result = query.get();
+    Iterable<String> names = names(result);
+    assertThat(names)
+        .named(format(query, result, projects))
+        .containsExactlyElementsIn(names(projects));
+    return result;
+  }
+
+  protected QueryRequest newQuery(Object query) {
+    return gApi.projects().query(query.toString());
+  }
+
+  protected String format(
+      QueryRequest query, List<ProjectInfo> actualProjects, List<ProjectInfo> expectedProjects) {
+    StringBuilder b = new StringBuilder();
+    b.append("query '").append(query.getQuery()).append("' with expected projects ");
+    b.append(format(expectedProjects));
+    b.append(" and result ");
+    b.append(format(actualProjects));
+    return b.toString();
+  }
+
+  protected String format(Iterable<ProjectInfo> projects) {
+    StringBuilder b = new StringBuilder();
+    b.append("[");
+    Iterator<ProjectInfo> it = projects.iterator();
+    while (it.hasNext()) {
+      ProjectInfo p = it.next();
+      b.append("{")
+          .append(p.id)
+          .append(", ")
+          .append("name=")
+          .append(p.name)
+          .append(", ")
+          .append("parent=")
+          .append(p.parent)
+          .append(", ")
+          .append("description=")
+          .append(p.description)
+          .append("}");
+      if (it.hasNext()) {
+        b.append(", ");
+      }
+    }
+    b.append("]");
+    return b.toString();
+  }
+
+  protected static Iterable<String> names(ProjectInfo... projects) {
+    return names(Arrays.asList(projects));
+  }
+
+  protected static Iterable<String> names(List<ProjectInfo> projects) {
+    return projects.stream().map(p -> p.name).collect(toList());
+  }
+
+  protected String name(String name) {
+    if (name == null) {
+      return null;
+    }
+
+    return name + "_" + getSanitizedMethodName();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
new file mode 100644
index 0000000..4a09d87
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.project;
+
+import com.google.gerrit.server.index.project.ProjectSchemaDefinitions;
+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 LuceneQueryProjectsTest extends AbstractQueryProjectsTest {
+  @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(
+            com.google.gerrit.server.index.project.ProjectSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        ProjectSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config luceneConfig = new Config(config);
+    InMemoryModule.setDefaults(luceneConfig);
+    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/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/Schema_150_to_151_Test.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/Schema_150_to_151_Test.java
index dcd1ae5..a06c7a0 100644
--- 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
@@ -15,27 +15,29 @@
 package com.google.gerrit.server.schema;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 
-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.gwtorm.jdbc.JdbcSchema;
 import com.google.inject.Inject;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -48,11 +50,40 @@
   @Inject private Schema_151 schema151;
 
   private ReviewDb db;
+  private Connection connection;
+  private PreparedStatement createdOnRetrieval;
+  private PreparedStatement createdOnUpdate;
+  private PreparedStatement auditEntryDeletion;
 
   @Before
   public void setUp() throws Exception {
     testEnv.getInjector().injectMembers(this);
     db = testEnv.getDb();
+    assume().that(db instanceof JdbcSchema).isTrue();
+
+    connection = ((JdbcSchema) db).getConnection();
+    createdOnRetrieval =
+        connection.prepareStatement("SELECT created_on FROM account_groups WHERE group_id = ?");
+    createdOnUpdate =
+        connection.prepareStatement("UPDATE account_groups SET created_on = ? WHERE group_id = ?");
+    auditEntryDeletion =
+        connection.prepareStatement("DELETE FROM account_group_members_audit WHERE group_id = ?");
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (auditEntryDeletion != null) {
+      auditEntryDeletion.close();
+    }
+    if (createdOnUpdate != null) {
+      createdOnUpdate.close();
+    }
+    if (createdOnRetrieval != null) {
+      createdOnRetrieval.close();
+    }
+    if (connection != null) {
+      connection.close();
+    }
   }
 
   @Test
@@ -63,8 +94,8 @@
 
     schema151.migrateData(db, new TestUpdateUI());
 
-    AccountGroup group = db.accountGroups().get(groupId);
-    assertThat(group.getCreatedOn()).isAtLeast(testStartTime);
+    Timestamp createdOn = getCreatedOn(groupId);
+    assertThat(createdOn).isAtLeast(testStartTime);
   }
 
   @Test
@@ -75,8 +106,8 @@
 
     schema151.migrateData(db, new TestUpdateUI());
 
-    AccountGroup group = db.accountGroups().get(groupId);
-    assertThat(group.getCreatedOn()).isEqualTo(AccountGroup.auditCreationInstantTs());
+    Timestamp createdOn = getCreatedOn(groupId);
+    assertThat(createdOn).isEqualTo(AccountGroup.auditCreationInstantTs());
   }
 
   private AccountGroup.Id createGroup(String name) throws Exception {
@@ -87,16 +118,26 @@
     return new Id(groupInfo.groupId);
   }
 
-  private void setCreatedOnToVeryOldTimestamp(Id groupId) throws OrmException {
-    AccountGroup group = db.accountGroups().get(groupId);
+  private Timestamp getCreatedOn(Id groupId) throws Exception {
+    createdOnRetrieval.setInt(1, groupId.get());
+    try (ResultSet results = createdOnRetrieval.executeQuery()) {
+      if (results.first()) {
+        return results.getTimestamp(1);
+      }
+    }
+    return null;
+  }
+
+  private void setCreatedOnToVeryOldTimestamp(Id groupId) throws Exception {
+    createdOnUpdate.setInt(1, groupId.get());
     Instant instant = LocalDateTime.of(1800, Month.JANUARY, 1, 0, 0).toInstant(ZoneOffset.UTC);
-    group.setCreatedOn(Timestamp.from(instant));
-    db.accountGroups().update(ImmutableList.of(group));
+    createdOnUpdate.setTimestamp(1, Timestamp.from(instant));
+    createdOnUpdate.setInt(2, groupId.get());
+    createdOnUpdate.executeUpdate();
   }
 
   private void removeAuditEntriesFor(AccountGroup.Id groupId) throws Exception {
-    ResultSet<AccountGroupMemberAudit> groupMemberAudits =
-        db.accountGroupMembersAudit().byGroup(groupId);
-    db.accountGroupMembersAudit().delete(groupMemberAudits);
+    auditEntryDeletion.setInt(1, groupId.get());
+    auditEntryDeletion.executeUpdate();
   }
 }
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 e036495..d542bc3 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
@@ -47,6 +47,7 @@
 import com.google.gerrit.server.config.TrackingFootersProvider;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.LocalMergeSuperSetComputation;
 import com.google.gerrit.server.git.PerThreadRequestScope;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.SendEmailExecutor;
@@ -57,6 +58,7 @@
 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.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
 import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
@@ -66,6 +68,7 @@
 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.project.DefaultProjectNameLockManager;
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
 import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
@@ -202,7 +205,7 @@
             return CanonicalWebUrlProvider.class;
           }
         });
-    //Replacement of DiffExecutorModule to not use thread pool in the tests
+    // Replacement of DiffExecutorModule to not use thread pool in the tests
     install(
         new AbstractModule() {
           @Override
@@ -220,6 +223,7 @@
     install(new SignedTokenEmailTokenVerifier.Module());
     install(new GpgModule(cfg));
     install(new InMemoryAccountPatchReviewStore.Module());
+    install(new LocalMergeSuperSetComputation.Module());
 
     bind(AllAccountsIndexer.class).toProvider(Providers.of(null));
     bind(AllChangesIndexer.class).toProvider(Providers.of(null));
@@ -246,6 +250,7 @@
     bind(ServerInformationImpl.class);
     bind(ServerInformation.class).to(ServerInformationImpl.class);
     install(new PluginRestApiModule());
+    install(new DefaultProjectNameLockManager.Module());
   }
 
   @Provides
@@ -292,6 +297,7 @@
     putSchemaVersion(singleVersions, AccountSchemaDefinitions.INSTANCE);
     putSchemaVersion(singleVersions, ChangeSchemaDefinitions.INSTANCE);
     putSchemaVersion(singleVersions, GroupSchemaDefinitions.INSTANCE);
+    putSchemaVersion(singleVersions, ProjectSchemaDefinitions.INSTANCE);
     return singleVersions;
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
index dd44cb9ae..5bbe3b6 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
@@ -17,18 +17,21 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
+import com.google.gerrit.common.TimeUtil;
 import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeUtils;
-import org.joda.time.DateTimeUtils.MillisProvider;
-import org.joda.time.DateTimeZone;
 
 /** Static utility methods for dealing with dates and times in tests. */
 public class TestTimeUtil {
-  public static final DateTime START =
-      new DateTime(2009, 9, 30, 17, 0, 0, DateTimeZone.forOffsetHours(-4));
+  public static final Instant START =
+      LocalDateTime.of(2009, Month.SEPTEMBER, 30, 17, 0, 0)
+          .atOffset(ZoneOffset.ofHours(-4))
+          .toInstant();
 
   private static Long clockStepMs;
   private static AtomicLong clockMs;
@@ -43,7 +46,7 @@
    */
   public static synchronized void resetWithClockStep(long clockStep, TimeUnit clockStepUnit) {
     // Set an arbitrary start point so tests are more repeatable.
-    clockMs = new AtomicLong(START.getMillis());
+    clockMs = new AtomicLong(START.toEpochMilli());
     setClockStep(clockStep, clockStepUnit);
   }
 
@@ -56,13 +59,7 @@
   public static synchronized void setClockStep(long clockStep, TimeUnit clockStepUnit) {
     checkState(clockMs != null, "call resetWithClockStep first");
     clockStepMs = MILLISECONDS.convert(clockStep, clockStepUnit);
-    DateTimeUtils.setCurrentMillisProvider(
-        new MillisProvider() {
-          @Override
-          public long getMillis() {
-            return clockMs.getAndAdd(clockStepMs);
-          }
-        });
+    TimeUtil.setCurrentMillisSupplier(() -> clockMs.getAndAdd(clockStepMs));
   }
 
   /**
@@ -89,7 +86,7 @@
   /** Reset the clock to use the actual system clock. */
   public static synchronized void useSystemTime() {
     clockMs = null;
-    DateTimeUtils.setCurrentMillisSystem();
+    TimeUtil.resetCurrentMillisSupplier();
   }
 
   private TestTimeUtil() {}
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-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
index b9a98b9..710b3dc 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
@@ -19,7 +19,6 @@
 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;
@@ -31,7 +30,7 @@
 
 public abstract class AbstractGitCommand extends BaseCommand {
   @Argument(index = 0, metaVar = "PROJECT.git", required = true, usage = "project name")
-  protected ProjectControl projectControl;
+  protected ProjectState projectState;
 
   @Inject private SshScope sshScope;
 
@@ -41,12 +40,9 @@
 
   @Inject private SshScope.Context context;
 
-  @Inject private IdentifiedUser user;
-
   @Inject private IdentifiedUser.GenericFactory userFactory;
 
   protected Repository repo;
-  protected ProjectState state;
   protected Project.NameKey projectName;
   protected Project project;
 
@@ -69,7 +65,7 @@
 
             @Override
             public Project.NameKey getProjectName() {
-              return projectControl.getProjectState().getNameKey();
+              return projectState.getNameKey();
             }
           });
     } finally {
@@ -88,8 +84,7 @@
   }
 
   private void service() throws IOException, PermissionBackendException, Failure {
-    state = projectControl.getProjectState();
-    project = state.getProject();
+    project = projectState.getProject();
     projectName = project.getNameKey();
 
     try {
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 6923ad1..fa3a0f5 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
@@ -79,6 +79,8 @@
 
   private ExitCallback exit;
 
+  @Inject protected CurrentUser user;
+
   @Inject private SshScope sshScope;
 
   @Inject private CmdLineParser.Factory cmdLineParserFactory;
@@ -88,7 +90,6 @@
   @Inject @CommandExecutor private ScheduledThreadPoolExecutor executor;
 
   @Inject private PermissionBackend permissionBackend;
-  @Inject private CurrentUser user;
 
   @Inject private SshScope.Context context;
 
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 1c55f48..d5fc4547 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
@@ -28,7 +28,7 @@
 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.project.ProjectState;
 import com.google.gerrit.sshd.BaseCommand.UnloggedFailure;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -67,15 +67,15 @@
   }
 
   public void addChange(
-      String id, Map<Change.Id, ChangeResource> changes, ProjectControl projectControl)
+      String id, Map<Change.Id, ChangeResource> changes, ProjectState projectState)
       throws UnloggedFailure, OrmException, PermissionBackendException {
-    addChange(id, changes, projectControl, true);
+    addChange(id, changes, projectState, true);
   }
 
   public void addChange(
       String id,
       Map<Change.Id, ChangeResource> changes,
-      ProjectControl projectControl,
+      ProjectState projectState,
       boolean useIndex)
       throws UnloggedFailure, OrmException, PermissionBackendException {
     List<ChangeNotes> matched = useIndex ? changeFinder.find(id) : changeFromNotesFactory(id);
@@ -89,7 +89,7 @@
     }
     for (ChangeNotes notes : matched) {
       if (!changes.containsKey(notes.getChangeId())
-          && inProject(projectControl, notes.getProjectName())
+          && inProject(projectState, notes.getProjectName())
           && (canMaintainServer
               || permissionBackend
                   .user(currentUser)
@@ -127,9 +127,9 @@
     }
   }
 
-  private boolean inProject(ProjectControl projectControl, Project.NameKey project) {
-    if (projectControl != null) {
-      return projectControl.getProject().getNameKey().equals(project);
+  private boolean inProject(ProjectState projectState, Project.NameKey project) {
+    if (projectState != null) {
+      return projectState.getNameKey().equals(project);
     }
 
     // No --project option, so they want every project.
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 0d7fa24..c6e00aa 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
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.sshd.commands;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -24,7 +26,6 @@
 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;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.sshd.CommandMetaData;
@@ -57,21 +58,21 @@
     metaVar = "NAME",
     usage = "new parent project"
   )
-  private ProjectControl newParent;
+  private ProjectState newParent;
 
   @Option(
     name = "--children-of",
     metaVar = "NAME",
     usage = "parent project for which the child projects should be reparented"
   )
-  private ProjectControl oldParent;
+  private ProjectState oldParent;
 
   @Option(
     name = "--exclude",
     metaVar = "NAME",
     usage = "child project of old parent project which should not be reparented"
   )
-  private List<ProjectControl> excludedChildren = new ArrayList<>();
+  private List<ProjectState> excludedChildren = new ArrayList<>();
 
   @Argument(
     index = 0,
@@ -80,7 +81,7 @@
     metaVar = "NAME",
     usage = "projects to modify"
   )
-  private List<ProjectControl> children = new ArrayList<>();
+  private List<ProjectState> children = new ArrayList<>();
 
   @Inject private ProjectCache projectCache;
 
@@ -125,10 +126,8 @@
       }
     }
 
-    final List<Project.NameKey> childProjects = new ArrayList<>();
-    for (ProjectControl pc : children) {
-      childProjects.add(pc.getProject().getNameKey());
-    }
+    final List<Project.NameKey> childProjects =
+        children.stream().map(ProjectState::getNameKey).collect(toList());
     if (oldParent != null) {
       try {
         childProjects.addAll(getChildrenForReparenting(oldParent));
@@ -174,7 +173,13 @@
         err.append("error: ").append(msg).append("\n");
       }
 
-      projectCache.evict(nameKey);
+      try {
+        projectCache.evict(nameKey);
+      } catch (IOException e) {
+        final String msg = "Cannot reindex project: " + name;
+        log.error(msg, e);
+        err.append("error: ").append(msg).append("\n");
+      }
     }
 
     if (err.length() > 0) {
@@ -190,18 +195,18 @@
    * list of child projects does not contain projects that were specified to be excluded from
    * reparenting.
    */
-  private List<Project.NameKey> getChildrenForReparenting(ProjectControl parent)
+  private List<Project.NameKey> getChildrenForReparenting(ProjectState parent)
       throws PermissionBackendException {
     final List<Project.NameKey> childProjects = new ArrayList<>();
     final List<Project.NameKey> excluded = new ArrayList<>(excludedChildren.size());
-    for (ProjectControl excludedChild : excludedChildren) {
+    for (ProjectState excludedChild : excludedChildren) {
       excluded.add(excludedChild.getProject().getNameKey());
     }
     final List<Project.NameKey> automaticallyExcluded = new ArrayList<>(excludedChildren.size());
     if (newParentKey != null) {
       automaticallyExcluded.addAll(getAllParents(newParentKey));
     }
-    for (ProjectInfo child : listChildProjects.apply(new ProjectResource(parent))) {
+    for (ProjectInfo child : listChildProjects.apply(new ProjectResource(parent, user))) {
       final Project.NameKey childName = new Project.NameKey(child.name);
       if (!excluded.contains(childName)) {
         if (!automaticallyExcluded.contains(childName)) {
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 d06f65c..d514e2c 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,10 +18,11 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.api.projects.BanCommitInput;
 import com.google.gerrit.server.project.BanCommit;
 import com.google.gerrit.server.project.BanCommit.BanResultInfo;
-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.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -51,7 +52,7 @@
     metaVar = "PROJECT",
     usage = "name of the project for which the commit should be banned"
   )
-  private ProjectControl projectControl;
+  private ProjectState projectState;
 
   @Argument(
     index = 1,
@@ -67,11 +68,11 @@
   @Override
   protected void run() throws Failure {
     try {
-      BanCommit.Input input =
-          BanCommit.Input.fromCommits(Lists.transform(commitsToBan, ObjectId::getName));
+      BanCommitInput input =
+          BanCommitInput.fromCommits(Lists.transform(commitsToBan, ObjectId::getName));
       input.reason = reason;
 
-      BanResultInfo r = banCommit.apply(new ProjectResource(projectControl), input);
+      BanResultInfo r = banCommit.apply(new ProjectResource(projectState, user), input);
       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");
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
index 5962faa..fd1e189 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -28,7 +28,7 @@
 public final class CreateBranchCommand extends SshCommand {
 
   @Argument(index = 0, required = true, metaVar = "PROJECT", usage = "name of the project")
-  private ProjectControl project;
+  private ProjectState project;
 
   @Argument(index = 1, required = true, metaVar = "NAME", usage = "name of branch to be created")
   private String name;
@@ -48,7 +48,7 @@
     try {
       BranchInput in = new BranchInput();
       in.revision = revision;
-      gApi.projects().name(project.getProject().getNameKey().get()).branch(name).create(in);
+      gApi.projects().name(project.getName()).branch(name).create(in);
     } catch (RestApiException e) {
       throw die(e);
     }
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 0df2a80..d6ecb0a 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
@@ -28,7 +28,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 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.server.project.SuggestParentCandidates;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -68,7 +68,7 @@
     metaVar = "NAME",
     usage = "parent project"
   )
-  private ProjectControl newParent;
+  private ProjectState newParent;
 
   @Option(name = "--permissions-only", usage = "create project for use only as parent")
   private boolean permissionsOnly;
@@ -188,7 +188,7 @@
           input.owners = Lists.transform(ownerIds, AccountGroup.UUID::get);
         }
         if (newParent != null) {
-          input.parent = newParent.getProject().getName();
+          input.parent = newParent.getName();
         }
         input.permissionsOnly = permissionsOnly;
         input.description = projectDescription;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
index b0b26fa..25f0e77 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
 import static com.google.gerrit.common.data.GlobalCapability.RUN_GC;
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GarbageCollectionResult;
@@ -24,7 +25,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GarbageCollection;
 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.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -54,7 +55,7 @@
     metaVar = "NAME",
     usage = "projects for which the Git garbage collection should be run"
   )
-  private List<ProjectControl> projects = new ArrayList<>();
+  private List<ProjectState> projects = new ArrayList<>();
 
   @Inject private ProjectCache projectCache;
 
@@ -80,10 +81,7 @@
     if (all) {
       projectNames = Lists.newArrayList(projectCache.all());
     } else {
-      projectNames = Lists.newArrayListWithCapacity(projects.size());
-      for (ProjectControl pc : projects) {
-        projectNames.add(pc.getProject().getNameKey());
-      }
+      projectNames = projects.stream().map(ProjectState::getNameKey).collect(toList());
     }
 
     GarbageCollectionResult result =
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 821257c..bb33dea 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,6 +14,7 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.Index;
@@ -55,7 +56,7 @@
     boolean ok = true;
     for (ChangeResource rsrc : changes.values()) {
       try {
-        index.apply(rsrc, new Index.Input());
+        index.apply(rsrc, new Input());
       } catch (Exception e) {
         ok = false;
         writeError(
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
index 476c25b..ba937a2 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
@@ -18,8 +18,8 @@
 
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.server.project.Index;
-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.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -40,7 +40,7 @@
     metaVar = "PROJECT",
     usage = "projects for which the changes should be indexed"
   )
-  private List<ProjectControl> projects = new ArrayList<>();
+  private List<ProjectState> projects = new ArrayList<>();
 
   @Override
   protected void run() throws UnloggedFailure, Failure, Exception {
@@ -50,14 +50,12 @@
     projects.stream().forEach(this::index);
   }
 
-  private void index(ProjectControl projectControl) {
+  private void index(ProjectState projectState) {
     try {
-      index.apply(new ProjectResource(projectControl), null);
+      index.apply(new ProjectResource(projectState, user), null);
     } catch (Exception e) {
       writeError(
-          "error",
-          String.format(
-              "Unable to index %s: %s", projectControl.getProject().getName(), e.getMessage()));
+          "error", String.format("Unable to index %s: %s", projectState.getName(), e.getMessage()));
     }
   }
 }
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 275da7c..e467cc4 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
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.sshd.CommandMetaData;
@@ -59,7 +59,7 @@
     required = true,
     usage = "project for which the refs should be listed"
   )
-  private ProjectControl projectControl;
+  private ProjectState projectState;
 
   @Option(
     name = "--user",
@@ -87,13 +87,13 @@
       return;
     }
 
-    Project.NameKey projectName = projectControl.getProject().getNameKey();
+    Project.NameKey projectName = projectState.getNameKey();
     try (Repository repo = repoManager.openRepository(projectName);
         ManualRequestContext ctx = requestContext.openAs(userAccount.getId())) {
       try {
         Map<String, Ref> refsMap =
             refFilterFactory
-                .create(projectControl.getProjectState(), repo)
+                .create(projectState, repo)
                 .filter(repo.getRefDatabase().getRefs(ALL), false);
 
         for (String ref : refsMap.keySet()) {
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 c3613b1..9fcd201 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
@@ -24,7 +24,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchChangeException;
-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.sshd.BaseCommand.UnloggedFailure;
@@ -57,15 +57,15 @@
     this.changeFinder = changeFinder;
   }
 
-  public PatchSet parsePatchSet(String token, ProjectControl projectControl, String branch)
+  public PatchSet parsePatchSet(String token, ProjectState projectState, String branch)
       throws UnloggedFailure, OrmException {
     // By commit?
     //
     if (token.matches("^([0-9a-fA-F]{4," + RevId.LEN + "})$")) {
       InternalChangeQuery query = queryProvider.get();
       List<ChangeData> cds;
-      if (projectControl != null) {
-        Project.NameKey p = projectControl.getProject().getNameKey();
+      if (projectState != null) {
+        Project.NameKey p = projectState.getNameKey();
         if (branch != null) {
           cds = query.byBranchCommit(p.get(), branch, token);
         } else {
@@ -77,7 +77,7 @@
       List<PatchSet> matches = new ArrayList<>(cds.size());
       for (ChangeData cd : cds) {
         Change c = cd.change();
-        if (!(inProject(c, projectControl) && inBranch(c, branch))) {
+        if (!(inProject(c, projectState) && inBranch(c, branch))) {
           continue;
         }
         for (PatchSet ps : cd.patchSets()) {
@@ -106,19 +106,15 @@
       } catch (IllegalArgumentException e) {
         throw error("\"" + token + "\" is not a valid patch set");
       }
-      ChangeNotes notes = getNotes(projectControl, patchSetId.getParentKey());
+      ChangeNotes notes = getNotes(projectState, patchSetId.getParentKey());
       PatchSet patchSet = psUtil.get(db.get(), notes, patchSetId);
       if (patchSet == null) {
         throw error("\"" + token + "\" no such patch set");
       }
-      if (projectControl != null || branch != null) {
+      if (projectState != null || branch != null) {
         Change change = notes.getChange();
-        if (!inProject(change, projectControl)) {
-          throw error(
-              "change "
-                  + change.getId()
-                  + " not in project "
-                  + projectControl.getProject().getName());
+        if (!inProject(change, projectState)) {
+          throw error("change " + change.getId() + " not in project " + projectState.getName());
         }
         if (!inBranch(change, branch)) {
           throw error("change " + change.getId() + " not in branch " + branch);
@@ -130,10 +126,10 @@
     throw error("\"" + token + "\" is not a valid patch set");
   }
 
-  private ChangeNotes getNotes(@Nullable ProjectControl projectControl, Change.Id changeId)
+  private ChangeNotes getNotes(@Nullable ProjectState projectState, Change.Id changeId)
       throws OrmException, UnloggedFailure {
-    if (projectControl != null) {
-      return notesFactory.create(db.get(), projectControl.getProject().getNameKey(), changeId);
+    if (projectState != null) {
+      return notesFactory.create(db.get(), projectState.getNameKey(), changeId);
     }
     try {
       ChangeNotes notes = changeFinder.findOne(changeId);
@@ -143,12 +139,12 @@
     }
   }
 
-  private static boolean inProject(Change change, ProjectControl projectControl) {
-    if (projectControl == null) {
+  private static boolean inProject(Change change, ProjectState projectState) {
+    if (projectState == null) {
       // No --project option, so they want every project.
       return true;
     }
-    return projectControl.getProject().getNameKey().equals(change.getProject());
+    return projectState.getNameKey().equals(change.getProject());
   }
 
   private static boolean inBranch(Change change, String branch) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
new file mode 100644
index 0000000..7e32615
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.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.sshd.commands;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.server.plugins.PluginLoader;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+public abstract class PluginAdminSshCommand extends SshCommand {
+  @Inject protected PluginLoader loader;
+
+  abstract void doRun() throws UnloggedFailure;
+
+  @Override
+  protected final void run() throws UnloggedFailure {
+    if (!loader.isRemoteAdminEnabled()) {
+      throw die("remote plugin administration is disabled");
+    }
+    doRun();
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
index d7c8f3a..baaf715 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
@@ -17,29 +17,18 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.server.plugins.PluginInstallException;
-import com.google.gerrit.server.plugins.PluginLoader;
 import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
 import java.util.List;
 import org.kohsuke.args4j.Argument;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "enable", description = "Enable plugins", runsAt = MASTER_OR_SLAVE)
-final class PluginEnableCommand extends SshCommand {
+final class PluginEnableCommand extends PluginAdminSshCommand {
   @Argument(index = 0, metaVar = "NAME", required = true, usage = "plugin(s) to enable")
   List<String> names;
 
-  @Inject private PluginLoader loader;
-
   @Override
-  protected void run() throws UnloggedFailure {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw die("remote plugin administration is disabled");
-    }
+  protected void doRun() throws UnloggedFailure {
     if (names != null && !names.isEmpty()) {
       try {
         loader.enablePlugins(Sets.newHashSet(names));
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
index 820052c..337eadb 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
@@ -17,13 +17,8 @@
 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.server.plugins.PluginInstallException;
-import com.google.gerrit.server.plugins.PluginLoader;
 import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -33,9 +28,8 @@
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "install", description = "Install/Add a plugin", runsAt = MASTER_OR_SLAVE)
-final class PluginInstallCommand extends SshCommand {
+final class PluginInstallCommand extends PluginAdminSshCommand {
   @Option(
     name = "--name",
     aliases = {"-n"},
@@ -51,14 +45,9 @@
   @Argument(index = 0, metaVar = "-|URL", usage = "JAR to load")
   private String source;
 
-  @Inject private PluginLoader loader;
-
   @SuppressWarnings("resource")
   @Override
-  protected void run() throws UnloggedFailure {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw die("remote installation is disabled");
-    }
+  protected void doRun() throws UnloggedFailure {
     if (Strings.isNullOrEmpty(source)) {
       throw die("Argument \"-|URL\" is required");
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
index 0f2c912..86a74d1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
@@ -16,30 +16,19 @@
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.server.plugins.InvalidPluginException;
 import com.google.gerrit.server.plugins.PluginInstallException;
-import com.google.gerrit.server.plugins.PluginLoader;
 import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
 import java.util.List;
 import org.kohsuke.args4j.Argument;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "reload", description = "Reload/Restart plugins", runsAt = MASTER_OR_SLAVE)
-final class PluginReloadCommand extends SshCommand {
+final class PluginReloadCommand extends PluginAdminSshCommand {
   @Argument(index = 0, metaVar = "NAME", usage = "plugins to reload/restart")
   private List<String> names;
 
-  @Inject private PluginLoader loader;
-
   @Override
-  protected void run() throws UnloggedFailure {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw die("remote plugin administration is disabled");
-    }
+  protected void doRun() throws UnloggedFailure {
     if (names == null || names.isEmpty()) {
       loader.rescan();
     } else {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
index 8a38739..0119349b 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
@@ -17,28 +17,17 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.server.plugins.PluginLoader;
 import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
 import java.util.List;
 import org.kohsuke.args4j.Argument;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "remove", description = "Disable plugins", runsAt = MASTER_OR_SLAVE)
-final class PluginRemoveCommand extends SshCommand {
+final class PluginRemoveCommand extends PluginAdminSshCommand {
   @Argument(index = 0, metaVar = "NAME", required = true, usage = "plugin to remove")
   List<String> names;
 
-  @Inject private PluginLoader loader;
-
   @Override
-  protected void run() throws UnloggedFailure {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw die("remote plugin administration is disabled");
-    }
+  protected void doRun() throws UnloggedFailure {
     if (names != null && !names.isEmpty()) {
       loader.disablePlugins(Sets.newHashSet(names));
     }
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 0f68d61..b199349 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
@@ -93,11 +93,15 @@
       throw new Failure(1, "fatal: unable to check permissions " + e);
     }
 
-    AsyncReceiveCommits arc = factory.create(projectControl, repo, null, reviewers);
+    AsyncReceiveCommits arc = factory.create(projectState, currentUser, repo, null, reviewers);
 
-    Capable r = arc.canUpload();
-    if (r != Capable.OK) {
-      throw die(r.getMessage());
+    try {
+      Capable r = arc.canUpload();
+      if (r != Capable.OK) {
+        throw die(r.getMessage());
+      }
+    } catch (PermissionBackendException e) {
+      throw die(e.getMessage());
     }
 
     ReceivePack rp = arc.getReceivePack();
@@ -110,9 +114,7 @@
       // we want to present this error to the user
       if (badStream.getCause() instanceof TooLargeObjectInPackException) {
         StringBuilder msg = new StringBuilder();
-        msg.append("Receive error on project \"")
-            .append(projectControl.getProject().getName())
-            .append("\"");
+        msg.append("Receive error on project \"").append(projectState.getName()).append("\"");
         msg.append(" (user ");
         msg.append(currentUser.getAccount().getUserName());
         msg.append(" account ");
@@ -127,9 +129,7 @@
       // Log what the heck is going on, as detailed as we can.
       //
       StringBuilder msg = new StringBuilder();
-      msg.append("Unpack error on project \"")
-          .append(projectControl.getProject().getName())
-          .append("\":\n");
+      msg.append("Unpack error on project \"").append(projectState.getName()).append("\":\n");
 
       msg.append("  AdvertiseRefsHook: ").append(rp.getAdvertiseRefsHook());
       if (rp.getAdvertiseRefsHook() == AdvertiseRefsHook.DEFAULT) {
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 6ec3a28..74dcc12 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,6 +14,7 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.gerrit.extensions.common.NameInput;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -48,7 +49,7 @@
   protected void run() throws Failure {
     try {
       GroupResource rsrc = groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName));
-      PutName.Input input = new PutName.Input();
+      NameInput input = new NameInput();
       input.name = newGroupName;
       putName.apply(rsrc, input);
     } catch (RestApiException | OrmException | IOException 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 2a82a26..1d764b9 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
@@ -34,7 +34,6 @@
 import com.google.gerrit.server.config.AllProjectsName;
 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.util.LabelVote;
 import com.google.gerrit.sshd.CommandMetaData;
@@ -81,7 +80,7 @@
   )
   void addPatchSetId(String token) {
     try {
-      PatchSet ps = psParser.parsePatchSet(token, projectControl, branch);
+      PatchSet ps = psParser.parsePatchSet(token, projectState, branch);
       patchSets.add(ps);
     } catch (UnloggedFailure e) {
       throw new IllegalArgumentException(e.getMessage(), e);
@@ -95,7 +94,7 @@
     aliases = "-p",
     usage = "project containing the specified patch set(s)"
   )
-  private ProjectControl projectControl;
+  private ProjectState projectState;
 
   @Option(name = "--branch", aliases = "-b", usage = "branch containing the specified patch set(s)")
   private String branch;
@@ -135,12 +134,6 @@
   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",
@@ -274,7 +267,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) {
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 033b4c6..16cc49a 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
@@ -22,7 +22,11 @@
 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.api.accounts.SshKeyInput;
 import com.google.gerrit.extensions.common.EmailInfo;
+import com.google.gerrit.extensions.common.HttpPasswordInput;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.common.NameInput;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -142,7 +146,6 @@
 
   @Inject private DeleteSshKey deleteSshKey;
 
-  private IdentifiedUser user;
   private AccountResource rsrc;
 
   @Override
@@ -178,7 +181,7 @@
       throws OrmException, IOException, UnloggedFailure, ConfigInvalidException,
           PermissionBackendException {
     user = genericUserFactory.create(id);
-    rsrc = new AccountResource(user);
+    rsrc = new AccountResource(user.asIdentifiedUser());
     try {
       for (String email : addEmails) {
         addEmail(email);
@@ -193,13 +196,13 @@
       }
 
       if (fullName != null) {
-        PutName.Input in = new PutName.Input();
+        NameInput in = new NameInput();
         in.name = fullName;
         putName.apply(rsrc, in);
       }
 
       if (httpPassword != null || clearHttpPassword) {
-        PutHttpPassword.Input in = new PutHttpPassword.Input();
+        HttpPasswordInput in = new HttpPasswordInput();
         in.httpPassword = httpPassword;
         putHttpPassword.apply(rsrc, in);
       }
@@ -232,7 +235,7 @@
       throws RestApiException, OrmException, IOException, ConfigInvalidException,
           PermissionBackendException {
     for (String sshKey : sshKeys) {
-      AddSshKey.Input in = new AddSshKey.Input();
+      SshKeyInput in = new SshKeyInput();
       in.raw = RawInputUtil.create(sshKey.getBytes(UTF_8), "plain/text");
       addSshKey.apply(rsrc, in);
     }
@@ -262,7 +265,7 @@
           ConfigInvalidException, PermissionBackendException {
     AccountSshKey sshKey =
         new AccountSshKey(new AccountSshKey.Id(user.getAccountId(), i.seq), i.sshPublicKey);
-    deleteSshKey.apply(new AccountResource.SshKey(user, sshKey), null);
+    deleteSshKey.apply(new AccountResource.SshKey(user.asIdentifiedUser(), sshKey), null);
   }
 
   private void addEmail(String email)
@@ -284,10 +287,10 @@
     if (email.equals("ALL")) {
       List<EmailInfo> emails = getEmails.apply(rsrc);
       for (EmailInfo e : emails) {
-        deleteEmail.apply(new AccountResource.Email(user, e.email), new DeleteEmail.Input());
+        deleteEmail.apply(new AccountResource.Email(user.asIdentifiedUser(), e.email), new Input());
       }
     } else {
-      deleteEmail.apply(new AccountResource.Email(user, email), new DeleteEmail.Input());
+      deleteEmail.apply(new AccountResource.Email(user.asIdentifiedUser(), email), new Input());
     }
   }
 
@@ -296,7 +299,7 @@
           ConfigInvalidException {
     for (EmailInfo e : getEmails.apply(rsrc)) {
       if (e.email.equals(email)) {
-        putPreferred.apply(new AccountResource.Email(user, email), null);
+        putPreferred.apply(new AccountResource.Email(user.asIdentifiedUser(), email), null);
         return;
       }
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetHeadCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
index ce4116d..ef7ab916 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.gerrit.extensions.api.projects.HeadInput;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-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.SetHead;
-import com.google.gerrit.server.project.SetHead.Input;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -29,7 +29,7 @@
 public class SetHeadCommand extends SshCommand {
 
   @Argument(index = 0, required = true, metaVar = "NAME", usage = "name of the project")
-  private ProjectControl project;
+  private ProjectState project;
 
   @Option(name = "--new-head", required = true, metaVar = "REF", usage = "new HEAD reference")
   private String newHead;
@@ -43,10 +43,10 @@
 
   @Override
   protected void run() throws Exception {
-    Input input = new SetHead.Input();
+    HeadInput input = new HeadInput();
     input.ref = newHead;
     try {
-      setHead.apply(new ProjectResource(project), input);
+      setHead.apply(new ProjectResource(project, user), input);
     } catch (UnprocessableEntityException e) {
       throw die(e);
     }
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 c275af8..a963a35 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
@@ -17,11 +17,11 @@
 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.extensions.restapi.RestApiException;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.PutConfig;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -32,7 +32,7 @@
 @CommandMetaData(name = "set-project", description = "Change a project's settings")
 final class SetProjectCommand extends SshCommand {
   @Argument(index = 0, required = true, metaVar = "NAME", usage = "name of the project")
-  private ProjectControl projectControl;
+  private ProjectState projectState;
 
   @Option(
     name = "--description",
@@ -148,19 +148,19 @@
     configInput.useContentMerge = contentMerge;
     configInput.useContributorAgreements = contributorAgreements;
     configInput.useSignedOffBy = signedOffBy;
-    configInput.state = state;
+    configInput.state = state.getProject().getState();
     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();
+      configInput.description = projectState.getProject().getDescription();
     }
 
     try {
-      putConfig.apply(new ProjectResource(projectControl), configInput);
-    } catch (RestApiException e) {
+      putConfig.apply(new ProjectResource(projectState, user), configInput);
+    } catch (RestApiException | PermissionBackendException 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 026f9b7..85cf467 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
@@ -24,7 +24,7 @@
 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.server.project.ProjectState;
 import com.google.gerrit.sshd.ChangeArgumentParser;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -46,7 +46,7 @@
   private static final Logger log = LoggerFactory.getLogger(SetReviewersCommand.class);
 
   @Option(name = "--project", aliases = "-p", usage = "project containing the change")
-  private ProjectControl projectControl;
+  private ProjectState projectState;
 
   @Option(
     name = "--add",
@@ -75,7 +75,7 @@
   )
   void addChange(String token) {
     try {
-      changeArgumentParser.addChange(token, changes, projectControl);
+      changeArgumentParser.addChange(token, changes, projectState);
     } catch (UnloggedFailure e) {
       throw new IllegalArgumentException(e.getMessage(), e);
     } catch (OrmException e) {
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 7049c7f..0d78279 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
@@ -51,8 +51,8 @@
   protected void runImpl() throws IOException, Failure {
     try {
       permissionBackend
-          .user(projectControl.getUser())
-          .project(projectControl.getProject().getNameKey())
+          .user(user)
+          .project(projectState.getNameKey())
           .check(ProjectPermission.RUN_UPLOAD_PACK);
     } catch (AuthException e) {
       throw new Failure(1, "fatal: upload-pack not permitted on this server");
@@ -61,7 +61,7 @@
     }
 
     final UploadPack up = new UploadPack(repo);
-    up.setAdvertiseRefsHook(refFilterFactory.create(projectControl.getProjectState(), repo));
+    up.setAdvertiseRefsHook(refFilterFactory.create(projectState, repo));
     up.setPackConfig(config.getPackConfig());
     up.setTimeout(config.getTimeout());
     up.setPostUploadHook(PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
@@ -71,7 +71,7 @@
         uploadValidatorsFactory.create(project, repo, session.getRemoteAddressAsString()));
     up.setPreUploadHook(PreUploadHookChain.newChain(allPreUploadHooks));
     for (UploadPackInitializer initializer : uploadPackInitializers) {
-      initializer.init(projectControl.getProject().getNameKey(), up);
+      initializer.init(projectState.getNameKey(), up);
     }
     try {
       up.upload(in, out, err);
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 9a3e6ab..41cc485b 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
@@ -18,7 +18,6 @@
 
 import com.google.common.collect.ImmutableMap;
 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;
@@ -122,7 +121,6 @@
 
   @Inject private PermissionBackend permissionBackend;
   @Inject private CommitsCollection commits;
-  @Inject private IdentifiedUser user;
   @Inject private AllowedFormats allowedFormats;
   private Options options = new Options();
 
@@ -250,7 +248,7 @@
       // Check reachability of the specific revision.
       try (RevWalk rw = new RevWalk(repo)) {
         RevCommit commit = rw.parseCommit(revId);
-        return commits.canRead(state, repo, commit);
+        return commits.canRead(projectState, repo, commit);
       }
     }
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
index b44f0fc..1858f40 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.sshd.CommandModule;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import java.util.ArrayList;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
@@ -55,15 +54,13 @@
   }
 
   private final DynamicItem<LfsSshPluginAuth> auth;
-  private final Provider<CurrentUser> user;
 
   @Argument(index = 0, multiValued = true, metaVar = "PARAMS")
   private List<String> args = new ArrayList<>();
 
   @Inject
-  LfsPluginAuthCommand(DynamicItem<LfsSshPluginAuth> auth, Provider<CurrentUser> user) {
+  LfsPluginAuthCommand(DynamicItem<LfsSshPluginAuth> auth) {
     this.auth = auth;
-    this.user = user;
   }
 
   @Override
@@ -74,6 +71,6 @@
       throw new UnloggedFailure(1, CONFIGURATION_ERROR);
     }
 
-    stdout.print(pluginAuth.authenticate(user.get(), args));
+    stdout.print(pluginAuth.authenticate(user, args));
   }
 }
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/client/RangeSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/client/RangeSubject.java
index 70f5ec6..40347a7 100644
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/client/RangeSubject.java
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/client/RangeSubject.java
@@ -16,28 +16,19 @@
 
 import static com.google.common.truth.Truth.assertAbout;
 
-import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.FailureMetadata;
 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 RangeSubject extends Subject<RangeSubject, Comment.Range> {
 
-  private static final SubjectFactory<RangeSubject, Comment.Range> RANGE_SUBJECT_FACTORY =
-      new SubjectFactory<RangeSubject, Comment.Range>() {
-        @Override
-        public RangeSubject getSubject(FailureStrategy failureStrategy, Comment.Range range) {
-          return new RangeSubject(failureStrategy, range);
-        }
-      };
-
   public static RangeSubject assertThat(Comment.Range range) {
-    return assertAbout(RANGE_SUBJECT_FACTORY).that(range);
+    return assertAbout(RangeSubject::new).that(range);
   }
 
-  private RangeSubject(FailureStrategy failureStrategy, Comment.Range range) {
-    super(failureStrategy, range);
+  private RangeSubject(FailureMetadata failureMetadata, Comment.Range range) {
+    super(failureMetadata, range);
   }
 
   public IntegerSubject startLine() {
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/CommitInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/CommitInfoSubject.java
index b2717af..37ae643 100644
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/CommitInfoSubject.java
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/CommitInfoSubject.java
@@ -16,30 +16,20 @@
 
 import static com.google.common.truth.Truth.assertAbout;
 
-import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.FailureMetadata;
 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.truth.ListSubject;
 
 public class CommitInfoSubject extends Subject<CommitInfoSubject, CommitInfo> {
 
-  private static final SubjectFactory<CommitInfoSubject, CommitInfo> COMMIT_INFO_SUBJECT_FACTORY =
-      new SubjectFactory<CommitInfoSubject, CommitInfo>() {
-        @Override
-        public CommitInfoSubject getSubject(
-            FailureStrategy failureStrategy, CommitInfo commitInfo) {
-          return new CommitInfoSubject(failureStrategy, commitInfo);
-        }
-      };
-
   public static CommitInfoSubject assertThat(CommitInfo commitInfo) {
-    return assertAbout(COMMIT_INFO_SUBJECT_FACTORY).that(commitInfo);
+    return assertAbout(CommitInfoSubject::new).that(commitInfo);
   }
 
-  private CommitInfoSubject(FailureStrategy failureStrategy, CommitInfo commitInfo) {
-    super(failureStrategy, commitInfo);
+  private CommitInfoSubject(FailureMetadata failureMetadata, CommitInfo commitInfo) {
+    super(failureMetadata, commitInfo);
   }
 
   public StringSubject commit() {
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
index 9c9893c..4e10ec4b 100644
--- 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
@@ -16,31 +16,21 @@
 
 import static com.google.common.truth.Truth.assertAbout;
 
-import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.FailureMetadata;
 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);
+    return assertAbout(ContentEntrySubject::new).that(contentEntry);
   }
 
-  private ContentEntrySubject(FailureStrategy failureStrategy, ContentEntry contentEntry) {
-    super(failureStrategy, contentEntry);
+  private ContentEntrySubject(FailureMetadata failureMetadata, ContentEntry contentEntry) {
+    super(failureMetadata, contentEntry);
   }
 
   public void isDueToRebase() {
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
index 1b1b847..a91c0ba 100644
--- 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
@@ -17,29 +17,20 @@
 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.FailureMetadata;
 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);
+    return assertAbout(DiffInfoSubject::new).that(diffInfo);
   }
 
-  private DiffInfoSubject(FailureStrategy failureStrategy, DiffInfo diffInfo) {
-    super(failureStrategy, diffInfo);
+  private DiffInfoSubject(FailureMetadata failureMetadata, DiffInfo diffInfo) {
+    super(failureMetadata, diffInfo);
   }
 
   public ListSubject<ContentEntrySubject, ContentEntry> content() {
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/EditInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/EditInfoSubject.java
index 95b2158..ea5f72e 100644
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/EditInfoSubject.java
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/EditInfoSubject.java
@@ -16,26 +16,17 @@
 
 import static com.google.common.truth.Truth.assertAbout;
 
-import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.FailureMetadata;
 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.truth.OptionalSubject;
 import java.util.Optional;
 
 public class EditInfoSubject extends Subject<EditInfoSubject, EditInfo> {
 
-  private static final SubjectFactory<EditInfoSubject, EditInfo> EDIT_INFO_SUBJECT_FACTORY =
-      new SubjectFactory<EditInfoSubject, EditInfo>() {
-        @Override
-        public EditInfoSubject getSubject(FailureStrategy failureStrategy, EditInfo editInfo) {
-          return new EditInfoSubject(failureStrategy, editInfo);
-        }
-      };
-
   public static EditInfoSubject assertThat(EditInfo editInfo) {
-    return assertAbout(EDIT_INFO_SUBJECT_FACTORY).that(editInfo);
+    return assertAbout(EditInfoSubject::new).that(editInfo);
   }
 
   public static OptionalSubject<EditInfoSubject, EditInfo> assertThat(
@@ -43,8 +34,8 @@
     return OptionalSubject.assertThat(editInfoOptional, EditInfoSubject::assertThat);
   }
 
-  private EditInfoSubject(FailureStrategy failureStrategy, EditInfo editInfo) {
-    super(failureStrategy, editInfo);
+  private EditInfoSubject(FailureMetadata failureMetadata, EditInfo editInfo) {
+    super(failureMetadata, editInfo);
   }
 
   public CommitInfoSubject commit() {
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
index f8cdb34..ac92634 100644
--- 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
@@ -17,28 +17,19 @@
 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.FailureMetadata;
 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);
+    return assertAbout(FileInfoSubject::new).that(fileInfo);
   }
 
-  private FileInfoSubject(FailureStrategy failureStrategy, FileInfo fileInfo) {
-    super(failureStrategy, fileInfo);
+  private FileInfoSubject(FailureMetadata failureMetadata, FileInfo fileInfo) {
+    super(failureMetadata, fileInfo);
   }
 
   public IntegerSubject linesInserted() {
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfoSubject.java
index f798622..811cc47 100644
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfoSubject.java
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfoSubject.java
@@ -16,33 +16,22 @@
 
 import static com.google.common.truth.Truth.assertAbout;
 
-import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.FailureMetadata;
 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.client.RangeSubject;
 
 public class FixReplacementInfoSubject
     extends Subject<FixReplacementInfoSubject, FixReplacementInfo> {
 
-  private static final SubjectFactory<FixReplacementInfoSubject, FixReplacementInfo>
-      FIX_REPLACEMENT_INFO_SUBJECT_FACTORY =
-          new SubjectFactory<FixReplacementInfoSubject, FixReplacementInfo>() {
-            @Override
-            public FixReplacementInfoSubject getSubject(
-                FailureStrategy failureStrategy, FixReplacementInfo fixReplacementInfo) {
-              return new FixReplacementInfoSubject(failureStrategy, fixReplacementInfo);
-            }
-          };
-
   public static FixReplacementInfoSubject assertThat(FixReplacementInfo fixReplacementInfo) {
-    return assertAbout(FIX_REPLACEMENT_INFO_SUBJECT_FACTORY).that(fixReplacementInfo);
+    return assertAbout(FixReplacementInfoSubject::new).that(fixReplacementInfo);
   }
 
   private FixReplacementInfoSubject(
-      FailureStrategy failureStrategy, FixReplacementInfo fixReplacementInfo) {
-    super(failureStrategy, fixReplacementInfo);
+      FailureMetadata failureMetadata, FixReplacementInfo fixReplacementInfo) {
+    super(failureMetadata, fixReplacementInfo);
   }
 
   public StringSubject path() {
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixSuggestionInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixSuggestionInfoSubject.java
index 9af4d1f..beaf6c1 100644
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixSuggestionInfoSubject.java
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixSuggestionInfoSubject.java
@@ -16,32 +16,21 @@
 
 import static com.google.common.truth.Truth.assertAbout;
 
-import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.FailureMetadata;
 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.truth.ListSubject;
 
 public class FixSuggestionInfoSubject extends Subject<FixSuggestionInfoSubject, FixSuggestionInfo> {
 
-  private static final SubjectFactory<FixSuggestionInfoSubject, FixSuggestionInfo>
-      FIX_SUGGESTION_INFO_SUBJECT_FACTORY =
-          new SubjectFactory<FixSuggestionInfoSubject, FixSuggestionInfo>() {
-            @Override
-            public FixSuggestionInfoSubject getSubject(
-                FailureStrategy failureStrategy, FixSuggestionInfo fixSuggestionInfo) {
-              return new FixSuggestionInfoSubject(failureStrategy, fixSuggestionInfo);
-            }
-          };
-
   public static FixSuggestionInfoSubject assertThat(FixSuggestionInfo fixSuggestionInfo) {
-    return assertAbout(FIX_SUGGESTION_INFO_SUBJECT_FACTORY).that(fixSuggestionInfo);
+    return assertAbout(FixSuggestionInfoSubject::new).that(fixSuggestionInfo);
   }
 
   private FixSuggestionInfoSubject(
-      FailureStrategy failureStrategy, FixSuggestionInfo fixSuggestionInfo) {
-    super(failureStrategy, fixSuggestionInfo);
+      FailureMetadata failureMetadata, FixSuggestionInfo fixSuggestionInfo) {
+    super(failureMetadata, fixSuggestionInfo);
   }
 
   public StringSubject fixId() {
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/GitPersonSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/GitPersonSubject.java
index 9ef06dc..5f29bc9 100644
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/GitPersonSubject.java
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/GitPersonSubject.java
@@ -17,28 +17,19 @@
 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.FailureMetadata;
 import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
 import com.google.common.truth.Truth;
 import java.sql.Timestamp;
 
 public class GitPersonSubject extends Subject<GitPersonSubject, GitPerson> {
 
-  private static final SubjectFactory<GitPersonSubject, GitPerson> GIT_PERSON_SUBJECT_FACTORY =
-      new SubjectFactory<GitPersonSubject, GitPerson>() {
-        @Override
-        public GitPersonSubject getSubject(FailureStrategy failureStrategy, GitPerson gitPerson) {
-          return new GitPersonSubject(failureStrategy, gitPerson);
-        }
-      };
-
   public static GitPersonSubject assertThat(GitPerson gitPerson) {
-    return assertAbout(GIT_PERSON_SUBJECT_FACTORY).that(gitPerson);
+    return assertAbout(GitPersonSubject::new).that(gitPerson);
   }
 
-  private GitPersonSubject(FailureStrategy failureStrategy, GitPerson gitPerson) {
-    super(failureStrategy, gitPerson);
+  private GitPersonSubject(FailureMetadata failureMetadata, GitPerson gitPerson) {
+    super(failureMetadata, gitPerson);
   }
 
   public ComparableSubject<?, Timestamp> creationDate() {
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/PathSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/PathSubject.java
index 307c19e..aebeee2 100644
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/PathSubject.java
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/PathSubject.java
@@ -16,25 +16,16 @@
 
 import static com.google.common.truth.Truth.assertAbout;
 
-import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
 import java.nio.file.Path;
 
 public class PathSubject extends Subject<PathSubject, Path> {
-  private static final SubjectFactory<PathSubject, Path> PATH_SUBJECT_FACTORY =
-      new SubjectFactory<PathSubject, Path>() {
-        @Override
-        public PathSubject getSubject(FailureStrategy failureStrategy, Path path) {
-          return new PathSubject(failureStrategy, path);
-        }
-      };
-
-  private PathSubject(FailureStrategy failureStrategy, Path path) {
-    super(failureStrategy, path);
+  private PathSubject(FailureMetadata failureMetadata, Path path) {
+    super(failureMetadata, path);
   }
 
   public static PathSubject assertThat(Path path) {
-    return assertAbout(PATH_SUBJECT_FACTORY).that(path);
+    return assertAbout(PathSubject::new).that(path);
   }
 }
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfoSubject.java
index afa1b9b..465d447 100644
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfoSubject.java
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfoSubject.java
@@ -16,24 +16,13 @@
 
 import static com.google.common.truth.Truth.assertAbout;
 
-import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
 import com.google.gerrit.truth.ListSubject;
 import java.util.List;
 
 public class RobotCommentInfoSubject extends Subject<RobotCommentInfoSubject, RobotCommentInfo> {
 
-  private static final SubjectFactory<RobotCommentInfoSubject, RobotCommentInfo>
-      ROBOT_COMMENT_INFO_SUBJECT_FACTORY =
-          new SubjectFactory<RobotCommentInfoSubject, RobotCommentInfo>() {
-            @Override
-            public RobotCommentInfoSubject getSubject(
-                FailureStrategy failureStrategy, RobotCommentInfo robotCommentInfo) {
-              return new RobotCommentInfoSubject(failureStrategy, robotCommentInfo);
-            }
-          };
-
   public static ListSubject<RobotCommentInfoSubject, RobotCommentInfo> assertThatList(
       List<RobotCommentInfo> robotCommentInfos) {
     return ListSubject.assertThat(robotCommentInfos, RobotCommentInfoSubject::assertThat)
@@ -41,12 +30,12 @@
   }
 
   public static RobotCommentInfoSubject assertThat(RobotCommentInfo robotCommentInfo) {
-    return assertAbout(ROBOT_COMMENT_INFO_SUBJECT_FACTORY).that(robotCommentInfo);
+    return assertAbout(RobotCommentInfoSubject::new).that(robotCommentInfo);
   }
 
   private RobotCommentInfoSubject(
-      FailureStrategy failureStrategy, RobotCommentInfo robotCommentInfo) {
-    super(failureStrategy, robotCommentInfo);
+      FailureMetadata failureMetadata, RobotCommentInfo robotCommentInfo) {
+    super(failureMetadata, robotCommentInfo);
   }
 
   public ListSubject<FixSuggestionInfoSubject, FixSuggestionInfo> fixSuggestions() {
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 30ac496..cf9df87 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
@@ -16,11 +16,10 @@
 
 import static com.google.common.truth.Truth.assertAbout;
 
-import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.FailureMetadata;
 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;
 import com.google.gerrit.truth.OptionalSubject;
 import java.io.ByteArrayOutputStream;
@@ -29,18 +28,8 @@
 
 public class BinaryResultSubject extends Subject<BinaryResultSubject, BinaryResult> {
 
-  private static final SubjectFactory<BinaryResultSubject, BinaryResult>
-      BINARY_RESULT_SUBJECT_FACTORY =
-          new SubjectFactory<BinaryResultSubject, BinaryResult>() {
-            @Override
-            public BinaryResultSubject getSubject(
-                FailureStrategy failureStrategy, BinaryResult binaryResult) {
-              return new BinaryResultSubject(failureStrategy, binaryResult);
-            }
-          };
-
   public static BinaryResultSubject assertThat(BinaryResult binaryResult) {
-    return assertAbout(BINARY_RESULT_SUBJECT_FACTORY).that(binaryResult);
+    return assertAbout(BinaryResultSubject::new).that(binaryResult);
   }
 
   public static OptionalSubject<BinaryResultSubject, BinaryResult> assertThat(
@@ -48,8 +37,8 @@
     return OptionalSubject.assertThat(binaryResultOptional, BinaryResultSubject::assertThat);
   }
 
-  private BinaryResultSubject(FailureStrategy failureStrategy, BinaryResult binaryResult) {
-    super(failureStrategy, binaryResult);
+  private BinaryResultSubject(FailureMetadata failureMetadata, BinaryResult binaryResult) {
+    super(failureMetadata, binaryResult);
   }
 
   public StringSubject asString() throws IOException {
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/truth/ListSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/truth/ListSubject.java
index e7f1074..bcd8dcf 100644
--- a/gerrit-test-util/src/main/java/com/google/gerrit/truth/ListSubject.java
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/truth/ListSubject.java
@@ -17,10 +17,9 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.truth.Truth.assertAbout;
 
-import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.IterableSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
 import java.util.List;
 import java.util.function.Function;
 
@@ -38,8 +37,8 @@
   }
 
   private ListSubject(
-      FailureStrategy failureStrategy, List<E> list, Function<E, S> elementAssertThatFunction) {
-    super(failureStrategy, list);
+      FailureMetadata failureMetadata, List<E> list, Function<E, S> elementAssertThatFunction) {
+    super(failureMetadata, list);
     this.elementAssertThatFunction = elementAssertThatFunction;
   }
 
@@ -71,7 +70,7 @@
   }
 
   private static class ListSubjectFactory<S extends Subject<S, T>, T>
-      extends SubjectFactory<IterableSubject, Iterable<?>> {
+      implements Subject.Factory<IterableSubject, Iterable<?>> {
 
     private Function<T, S> elementAssertThatFunction;
 
@@ -81,10 +80,10 @@
 
     @SuppressWarnings("unchecked")
     @Override
-    public ListSubject<S, T> getSubject(FailureStrategy failureStrategy, Iterable<?> objects) {
+    public ListSubject<S, T> createSubject(FailureMetadata failureMetadata, Iterable<?> objects) {
       // The constructor of ListSubject only accepts lists.
       // -> Casting is appropriate.
-      return new ListSubject<>(failureStrategy, (List<T>) objects, elementAssertThatFunction);
+      return new ListSubject<>(failureMetadata, (List<T>) objects, elementAssertThatFunction);
     }
   }
 }
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/truth/OptionalSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/truth/OptionalSubject.java
index 49e91a8..f24b5da 100644
--- a/gerrit-test-util/src/main/java/com/google/gerrit/truth/OptionalSubject.java
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/truth/OptionalSubject.java
@@ -17,9 +17,8 @@
 import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.truth.DefaultSubject;
-import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
-import com.google.common.truth.SubjectFactory;
 import com.google.common.truth.Truth;
 import java.util.Optional;
 import java.util.function.Function;
@@ -47,10 +46,10 @@
   }
 
   private OptionalSubject(
-      FailureStrategy failureStrategy,
+      FailureMetadata failureMetadata,
       Optional<T> optional,
       Function<? super T, ? extends S> valueAssertThatFunction) {
-    super(failureStrategy, optional);
+    super(failureMetadata, optional);
     this.valueAssertThatFunction = valueAssertThatFunction;
   }
 
@@ -82,7 +81,7 @@
   }
 
   private static class OptionalSubjectFactory<S extends Subject<S, ? super T>, T>
-      extends SubjectFactory<OptionalSubject<S, T>, Optional<T>> {
+      implements Subject.Factory<OptionalSubject<S, T>, Optional<T>> {
 
     private Function<? super T, ? extends S> valueAssertThatFunction;
 
@@ -91,8 +90,9 @@
     }
 
     @Override
-    public OptionalSubject<S, T> getSubject(FailureStrategy failureStrategy, Optional<T> optional) {
-      return new OptionalSubject<>(failureStrategy, optional, valueAssertThatFunction);
+    public OptionalSubject<S, T> createSubject(
+        FailureMetadata failureMetadata, Optional<T> optional) {
+      return new OptionalSubject<>(failureMetadata, optional, valueAssertThatFunction);
     }
   }
 }
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 9a02fcd..45270fa 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
@@ -50,6 +50,7 @@
 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.LocalMergeSuperSetComputation;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.receive.ReceiveCommitsExecutorModule;
@@ -65,6 +66,7 @@
 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.project.DefaultProjectNameLockManager;
 import com.google.gerrit.server.schema.DataSourceModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.DataSourceType;
@@ -325,6 +327,7 @@
     modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
     modules.add(new SmtpEmailSender.Module());
     modules.add(new SignedTokenEmailTokenVerifier.Module());
+    modules.add(new LocalMergeSuperSetComputation.Module());
 
     // Plugin module needs to be inserted *before* the index module.
     // There is the concept of LifecycleModule, in Gerrit's own extension
@@ -368,6 +371,7 @@
     modules.add(new ChangeCleanupRunner.Module());
     modules.add(new AccountDeactivator.Module());
     modules.addAll(LibModuleLoader.loadModules(cfgInjector));
+    modules.add(new DefaultProjectNameLockManager.Module());
     return cfgInjector.createChildInjector(modules);
   }
 
diff --git a/gerrit-war/src/main/resources/log4j.properties b/gerrit-war/src/main/resources/log4j.properties
index 8bc9bb2..28c0ee4 100644
--- a/gerrit-war/src/main/resources/log4j.properties
+++ b/gerrit-war/src/main/resources/log4j.properties
@@ -46,9 +46,5 @@
 log4j.logger.com.mchange.v2.resourcepool=WARN
 log4j.logger.com.mchange.v2.sql=WARN
 
-# Silence non-critical messages from Velocity
-#
-log4j.logger.velocity=WARN
-
 # Silence non-critical messages from apache.http
 log4j.logger.org.apache.http=WARN
diff --git a/lib/BUILD b/lib/BUILD
index 91334cb..62e0c2a 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -83,18 +83,6 @@
 )
 
 java_library(
-    name = "velocity",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@velocity//jar"],
-    runtime_deps = [
-        "//lib/commons:collections",
-        "//lib/commons:lang",
-        "//lib/commons:oro",
-    ],
-)
-
-java_library(
     name = "jsch",
     data = ["//lib:LICENSE-jsch"],
     visibility = ["//visibility:public"],
diff --git a/lib/elasticsearch/BUILD b/lib/elasticsearch/BUILD
index c40925e..18c62af 100644
--- a/lib/elasticsearch/BUILD
+++ b/lib/elasticsearch/BUILD
@@ -8,13 +8,13 @@
         ":compress-lzf",
         ":hppc",
         ":jna",
+        ":joda-time",
         ":jsr166e",
         ":netty",
         ":t-digest",
         "//lib/jackson:jackson-core",
         "//lib/jackson:jackson-dataformat-cbor",
         "//lib/jackson:jackson-dataformat-smile",
-        "//lib/joda:joda-time",
         "//lib/lucene:lucene-codecs",
         "//lib/lucene:lucene-highlighter",
         "//lib/lucene:lucene-join",
@@ -48,6 +48,19 @@
 )
 
 java_library(
+    name = "joda-time",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@joda_time//jar"],
+    runtime_deps = ["joda-convert"],
+)
+
+java_library(
+    name = "joda-convert",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@joda_convert//jar"],
+)
+
+java_library(
     name = "compress-lzf",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//lib/elasticsearch:__pkg__"],
diff --git a/lib/guava.bzl b/lib/guava.bzl
index 768b99e..b5df4ea 100644
--- a/lib/guava.bzl
+++ b/lib/guava.bzl
@@ -1,5 +1,5 @@
-GUAVA_VERSION = "22.0"
+GUAVA_VERSION = "23.0"
 
-GUAVA_BIN_SHA1 = "3564ef3803de51fb0530a8377ec6100b33b0d073"
+GUAVA_BIN_SHA1 = "c947004bb13d18182be60077ade044099e4f26f1"
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
diff --git a/lib/joda/BUILD b/lib/joda/BUILD
deleted file mode 100644
index e1a1924..0000000
--- a/lib/joda/BUILD
+++ /dev/null
@@ -1,13 +0,0 @@
-java_library(
-    name = "joda-time",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@joda_time//jar"],
-    runtime_deps = ["joda-convert"],
-)
-
-java_library(
-    name = "joda-convert",
-    data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@joda_convert//jar"],
-)
diff --git a/plugins/replication b/plugins/replication
index 8bc97a1..26448d2 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 8bc97a106a46a0f32350396e74769af11ec7b98e
+Subproject commit 26448d2bf3621fcf897df8db091c975b2b228a83
diff --git a/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command.html b/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command.html
new file mode 100644
index 0000000..6bf2211
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command.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/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+
+<dom-module id="gr-project-command">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+        margin-bottom: 2em;
+      }
+    </style>
+    <h3>[[title]]</h3>
+    <gr-button on-tap="_onCommandTap">[[title]]</gr-button>
+  </template>
+  <script src="gr-project-command.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command.js b/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command.js
new file mode 100644
index 0000000..48789b0
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command.js
@@ -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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-project-command',
+
+    properties: {
+      title: String,
+    },
+
+    /**
+     * Fired when command button is tapped.
+     *
+     * @event command-tap
+     */
+
+    _onCommandTap() {
+      this.dispatchEvent(new CustomEvent('command-tap', {bubbles: true}));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command_test.html b/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command_test.html
new file mode 100644
index 0000000..8fae4f8
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command_test.html
@@ -0,0 +1,50 @@
+<!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-command</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-command.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-project-command></gr-project-command>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-project-command tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('dispatched command-tap on button tap', done => {
+      element.addEventListener('command-tap', () => {
+        done();
+      });
+      MockInteractions.tap(
+          Polymer.dom(element.root).querySelector('gr-button'));
+    });
+  });
+</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
index 6c0908a..f43403a 100644
--- 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
@@ -19,10 +19,12 @@
 <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="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.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">
+<link rel="import" href="../gr-project-command/gr-project-command.html">
 
 <dom-module id="gr-project-commands">
   <template>
@@ -47,24 +49,23 @@
       <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>
+          <gr-project-command
+              title="Create Change"
+              on-command-tap="_createNewChange">
+          </gr-project-command>
+
+          <gr-project-command
+              title="Run GC"
+              hidden$="[[!_projectConfig.actions.gc.enabled]]"
+              on-command-tap="_handleRunningGC">
+          </gr-project-command>
+
+          <gr-endpoint-decorator name="project-command">
+            <gr-endpoint-param name="config" value="[[_projectConfig]]">
+            </gr-endpoint-param>
+            <gr-endpoint-param name="projectName" value="[[project]]">
+            </gr-endpoint-param>
+          </gr-endpoint-decorator>
         </div>
       </div>
     </main>
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 a96b6b0..6c31a5b 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
@@ -19,6 +19,7 @@
 <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">
+<link rel="import" href="../gr-user-header/gr-user-header.html">
 
 <dom-module id="gr-dashboard-view">
   <template>
@@ -34,6 +35,9 @@
       gr-change-list {
         width: 100%;
       }
+      .hide {
+        display: none;
+      }
       @media only screen and (max-width: 50em) {
         .loading {
           padding: 0 var(--default-horizontal-margin);
@@ -42,6 +46,9 @@
     </style>
     <div class="loading" hidden$="[[!_loading]]">Loading...</div>
     <div hidden$="[[_loading]]" hidden>
+      <gr-user-header
+          user-id="[[params.user]]"
+          class$="[[_computeUserHeaderClass(params.user)]]"></gr-user-header>
       <gr-change-list
           show-reviewed-state
           account="[[account]]"
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 6c5bad3..4d39a90 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,25 +14,39 @@
 (function() {
   'use strict';
 
+  // NOTE: These queries are tested in Java. Any changes made to definitions
+  // here require corresponding changes to:
+  // gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
   const DEFAULT_SECTIONS = [
     {
+      // WIP open changes owned by viewing user. This section is omitted when
+      // viewing other users, so we don't need to filter anything out.
       name: 'Work in progress',
       query: 'is:open owner:${user} is:wip',
       selfOnly: true,
     },
     {
+      // Non-WIP open changes owned by viewed user. Filter out changes ignored
+      // by the viewing user.
       name: 'Outgoing reviews',
-      query: 'is:open owner:${user} -is:wip',
+      query: 'is:open owner:${user} -is:wip -is:ignored',
     },
     {
+      // Non-WIP open changes not owned by the viewed user, that the viewed user
+      // is associated with (as either a reviewer or the assignee). Changes
+      // ignored by the viewing user are filtered out.
       name: 'Incoming reviews',
-      query: 'is:open ((reviewer:${user} -owner:${user} -is:ignored) OR ' +
-          'assignee:${user}) -is:wip',
+      query: 'is:open -owner:${user} -is:wip -is:ignored ' +
+          '(reviewer:${user} OR assignee:${user})',
     },
     {
       name: 'Recently closed',
-      query: 'is:closed (owner:${user} OR reviewer:${user} OR ' +
-          'assignee:${user})',
+      // Closed changes where viewed user is owner, reviewer, or assignee.
+      // Changes ignored by the viewing user are filtered out, and so are WIP
+      // changes not owned by the viewing user (the one instance of
+      // 'owner:self' is intentional and implements this logic).
+      query: 'is:closed -is:ignored (-is:wip OR owner:self) ' +
+          '(owner:${user} OR reviewer:${user} OR assignee:${user})',
       suffixForDashboard: '-age:4w limit:10',
     },
   ];
@@ -53,6 +67,8 @@
       },
       /** @type {{ selectedChangeIndex: number }} */
       viewState: Object,
+
+      /** @type {{ user: string }} */
       params: {
         type: Object,
       },
@@ -73,7 +89,7 @@
     },
 
     observers: [
-      '_userChanged(params.user)',
+      '_paramsChanged(params.*)',
     ],
 
     behaviors: [
@@ -95,20 +111,28 @@
       return 'Dashboard for ' + user;
     },
 
-    /**
-     * Allows a refresh if menu item is selected again.
-     */
-    _userChanged(user) {
-      if (!user) { return; }
+    _paramsChanged(paramsChangeRecord) {
+      const params = paramsChangeRecord.base;
+
+      if (!params.user && !params.sections) {
+        return;
+      }
+
+      const user = params.user || 'self';
+      const sections = (params.sections || DEFAULT_SECTIONS).filter(
+          section => (user === 'self' || !section.selfOnly));
+      const title = params.title || this._computeTitle(user);
 
       // 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.async(() => this.fire('title-change', {title}));
+
+      // Return if params indicate no longer in view.
+      if (!user && sections === DEFAULT_SECTIONS) {
+        return;
+      }
 
       this._loading = true;
-      const sections = this._sectionMetadata.filter(
-          section => (user === 'self' || !section.selfOnly));
       const queries =
           sections.map(
               section => this._dashboardQueryForSection(section, user));
@@ -136,5 +160,8 @@
       return query.replace(/\$\{user\}/g, user);
     },
 
+    _computeUserHeaderClass(userParam) {
+      return userParam === 'self' ? 'hide' : '';
+    },
   });
 })();
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 2edf26f..40376ad 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
@@ -64,17 +64,18 @@
     });
 
     test('viewing another user\'s dashboard omits selfOnly sections', () => {
-      element._sectionMetadata = [
-        {query: '1'},
-        {query: '2', selfOnly: true},
-      ];
-
-      element.params = {user: 'self'};
+      element.params = {
+        sections: [
+          {query: '1'},
+          {query: '2', selfOnly: true},
+        ],
+        user: 'self',
+      };
       flushAsynchronousOperations();
       assert.isTrue(
           getChangesStub.calledWith(null, ['1', '2'], null, element.options));
 
-      element.params = {user: 'user'};
+      element.set('params.user', 'user');
       flushAsynchronousOperations();
       assert.isTrue(
           getChangesStub.calledWith(null, ['1'], null, element.options));
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 3ac3a57..ec35d2d 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,12 +14,15 @@
 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="../../../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/shared-styles.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="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
@@ -29,17 +32,15 @@
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
 <link rel="import" href="../gr-change-actions/gr-change-actions.html">
 <link rel="import" href="../gr-change-metadata/gr-change-metadata.html">
 <link rel="import" href="../gr-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-file-list/gr-file-list.html">
 <link rel="import" href="../gr-messages-list/gr-messages-list.html">
 <link rel="import" href="../gr-related-changes-list/gr-related-changes-list.html">
 <link rel="import" href="../gr-reply-dialog/gr-reply-dialog.html">
-<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-change-view">
   <template>
@@ -436,6 +437,7 @@
             diff-view-mode="{{viewState.diffMode}}"
             patch-num="{{_patchRange.patchNum}}"
             base-patch-num="{{_patchRange.basePatchNum}}"
+            files-expanded="[[_filesExpanded]]"
             revisions="[[_sortedRevisions]]"
             on-open-diff-prefs="_handleOpenDiffPrefs"
             on-open-download-dialog="_handleOpenDownloadDialog"
@@ -455,9 +457,12 @@
             diff-view-mode="[[viewState.diffMode]]"
             edit-loaded="[[_editLoaded]]"
             num-files-shown="{{_numFilesShown}}"
+            files-expanded="{{_filesExpanded}}"
             file-list-increment="{{_numFilesShown}}"
             on-files-shown-changed="_setShownFiles"></gr-file-list>
       </section>
+      <gr-endpoint-decorator name="change-view-integration">
+      </gr-endpoint-decorator>
       <gr-messages-list id="messageList"
           class="hideOnMobileOverlay"
           change-num="[[_changeNum]]"
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 676e3de..542c041 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
@@ -148,6 +148,7 @@
       // new patches. This is just the initial setting from the change view vs.
       // an update coming from the two way data binding.
       _patchNum: String,
+      _filesExpanded: String,
       _basePatchNum: String,
       _relatedChangesLoading: {
         type: Boolean,
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-constants.html b/polygerrit-ui/app/elements/change/gr-file-list-constants.html
new file mode 100644
index 0000000..cfc129c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-constants.html
@@ -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.
+-->
+<script>
+  (function(window) {
+    'use strict';
+
+    const GrFileListConstants = window.GrFileListConstants || {};
+
+    GrFileListConstants.FilesExpandedState = {
+      ALL: 'all',
+      NONE: 'none',
+      SOME: 'some',
+    };
+
+    window.GrFileListConstants = GrFileListConstants;
+  })(window);
+</script>
\ No newline at end of file
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
index fd07d4e..ee22da86 100644
--- 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
@@ -19,11 +19,13 @@
 <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="../../edit/gr-edit-controls/gr-edit-controls.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">
-
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
+<link rel="import" href="../gr-file-list-constants.html">
 
 <dom-module id="gr-file-list-header">
   <template>
@@ -42,7 +44,6 @@
       }
       .patchInfo-header {
         background-color: #fafafa;
-        border-bottom: 1px solid #ddd;
         border-top: 1px solid #ddd;
         display: flex;
         min-height: 3.2em;
@@ -73,26 +74,12 @@
       .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;
+      .downloadContainer {
+        margin-right: 1em;
       }
       .fileList-header {
         align-items: center;
@@ -103,16 +90,37 @@
         padding: 0 .25em;
       }
       .rightControls {
+        align-self: flex-end;
+        margin: auto 0 auto auto;
         align-items: center;
         display: flex;
         flex-wrap: wrap;
         font-weight: normal;
         justify-content: flex-end;
       }
+      #collapseBtn,
+      .expanded #expandBtn,
+      .fileViewActions{
+        display: none;
+      }
+      .expanded #expandBtn {
+        display: none;
+      }
+      gr-button.selected iron-icon {
+        color: var(--color-link);
+      }
+      .expanded #collapseBtn,
+      .openFile .fileViewActions {
+        align-items: center;
+        display: flex;
+      }
+      .fileViewActions > *:not(:last-child) {
+        margin-right: .6em;
+      }
       .separator {
         background-color: rgba(0, 0, 0, .3);
         height: 1.5em;
-        margin: 0 .6em;
+        margin: 0 1em;
         width: 1px;
       }
       .separator.transparent {
@@ -124,9 +132,15 @@
       .editLoaded .hideOnEdit {
         display: none;
       }
+      .showOnEdit {
+        display: none;
+      }
       .editLoaded .showOnEdit {
         display: initial;
       }
+      .editLoaded .showOnEdit.flexContainer {
+        display: flex;
+      }
       .label {
         font-family: var(--font-family-bold);
         margin-right: 1em;
@@ -160,12 +174,6 @@
             <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 descriptionContainer hideOnEdit">
             <span class="separator"></span>
             <gr-editable-label
@@ -178,45 +186,63 @@
                 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 class$="rightControls [[_computeExpandedClass(filesExpanded)]]">
+          <span class="showOnEdit flexContainer">
+            <gr-edit-controls id="editControls" change="[[change]]"></gr-edit-controls>
+            <span class="separator"></span>
+          </span>
+          <span class="downloadContainer desktop">
+            <gr-button link
+                class="download"
+                on-tap="_handleDownloadTap">Download</gr-button>
+          </span>
+          <template is="dom-if"
+              if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
+            <gr-button
+                id="expandBtn"
+                link
+                on-tap="_expandAllDiffs">Expand All</gr-button>
+            <gr-button
+                id="collapseBtn"
+                link
+                on-tap="_collapseAllDiffs">Collapse All</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>
+          <div class="fileViewActions">
+            <span class="separator"></span>
+            <span>Diff Views:</span>
+            <gr-button
+                id="sideBySideBtn"
+                link
+                has-tooltip
+                title="Side-by-side diff"
+                class$="[[_computeSelectedClass(diffViewMode, _VIEW_MODES.SIDE_BY_SIDE)]]"
+                on-tap="_handleSideBySideTap"><iron-icon icon="gr-icons:side-by-side"></iron-icon></gr-button>
+            <gr-button
+                id="unifiedBtn"
+                link
+                has-tooltip
+                title="Unified dff"
+                class$="[[_computeSelectedClass(diffViewMode, _VIEW_MODES.UNIFIED)]]"
+                on-tap="_handleUnifiedTap"><iron-icon icon="gr-icons:unified"></iron-icon></gr-button>
+            <span id="diffPrefsContainer"
+                class="hideOnEdit"
+                hidden$="[[_computePrefsButtonHidden(diffPrefs, loggedIn)]]"
+                hidden>
+              <gr-button
+                  link
+                  has-tooltip
+                  title="Diff preferences"
+                  class="prefsButton desktop"
+                  on-tap="_handlePrefsTap"><iron-icon icon="gr-icons:settings"></iron-icon></gr-button>
+            </span>
           </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>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
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
index ace880a..36cfe06 100644
--- 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
@@ -40,6 +40,7 @@
       },
       patchNum: String,
       basePatchNum: String,
+      filesExpanded: String,
       revisions: Array,
       // Caps the number of files that can be shown and have the 'show diffs' /
       // 'hide diffs' buttons still be functional.
@@ -52,6 +53,15 @@
         type: Boolean,
         computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
       },
+      /** @type {?} */
+      _VIEW_MODES: {
+        type: Object,
+        readOnly: true,
+        value: {
+          SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+          UNIFIED: 'UNIFIED_DIFF',
+        },
+      },
     },
 
     behaviors: [
@@ -59,13 +69,39 @@
     ],
 
     _expandAllDiffs() {
+      this._expanded = true;
       this.fire('expand-diffs');
     },
 
     _collapseAllDiffs() {
+      this._expanded = false;
       this.fire('collapse-diffs');
     },
 
+    _computeSelectedClass(diffViewMode, buttonViewMode) {
+      return buttonViewMode === diffViewMode ? 'selected' : '';
+    },
+
+    _computeExpandedClass(filesExpanded) {
+      const classes = [];
+      if (filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
+        classes.push('expanded');
+      }
+      if (filesExpanded === GrFileListConstants.FilesExpandedState.SOME ||
+            filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
+        classes.push('openFile');
+      }
+      return classes.join(' ');
+    },
+
+    _handleSideBySideTap() {
+      this.diffViewMode = this._VIEW_MODES.SIDE_BY_SIDE;
+    },
+
+    _handleUnifiedTap() {
+      this.diffViewMode = this._VIEW_MODES.UNIFIED;
+    },
+
     _computeDescriptionPlaceholder(readOnly) {
       return (readOnly ? 'No' : 'Add') + ' patchset description';
     },
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
index 4c079ed..3a2fd63 100644
--- 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
@@ -191,14 +191,51 @@
     });
 
     test('diff mode selector is set correctly', () => {
-      const select = element.$.modeSelect;
+      const sideBySideBtn = element.$.sideBySideBtn;
+      const unifiedBtn = element.$.unifiedBtn;
       element.diffViewMode = 'SIDE_BY_SIDE';
       flushAsynchronousOperations();
-      assert.equal(select.nativeSelect.value, 'SIDE_BY_SIDE');
-
+      assert.isTrue(sideBySideBtn.classList.contains('selected'));
+      assert.isFalse(unifiedBtn.classList.contains('selected'));
       element.diffViewMode = 'UNIFIED_DIFF';
       flushAsynchronousOperations();
-      assert.equal(select.nativeSelect.value, 'UNIFIED_DIFF');
+      assert.isFalse(sideBySideBtn.classList.contains('selected'));
+      assert.isTrue(unifiedBtn.classList.contains('selected'));
+    });
+
+    test('fileViewActions are properly hidden', () => {
+      const actions = element.$$('.fileViewActions');
+      assert.equal(getComputedStyle(actions).display, 'none');
+      element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
+      flushAsynchronousOperations();
+      assert.notEqual(getComputedStyle(actions).display, 'none');
+      element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
+      flushAsynchronousOperations();
+      assert.notEqual(getComputedStyle(actions).display, 'none');
+      element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(actions).display, 'none');
+    });
+
+    test('expand/collapse buttons are toggled correctly', () => {
+      element.shownFileCount = 10;
+      flushAsynchronousOperations();
+      const expandBtn = element.$$('#expandBtn');
+      const collapseBtn = element.$$('#collapseBtn');
+      assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+      assert.equal(getComputedStyle(collapseBtn).display, 'none');
+      element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
+      flushAsynchronousOperations();
+      assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+      assert.equal(getComputedStyle(collapseBtn).display, 'none');
+      element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(expandBtn).display, 'none');
+      assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
+      element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
+      flushAsynchronousOperations();
+      assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+      assert.equal(getComputedStyle(collapseBtn).display, 'none');
     });
 
     test('navigateToChange called when range select changes', () => {
@@ -257,6 +294,16 @@
         assert.isTrue(isVisible(element.$$('.descriptionContainer')));
         assert.isTrue(isVisible(element.$.diffPrefsContainer));
       });
+
+      test('edit-controls visibility', () => {
+        element.editLoaded = true;
+        flushAsynchronousOperations();
+        assert.isTrue(isVisible(element.$.editControls.parentElement));
+
+        element.editLoaded = false;
+        flushAsynchronousOperations();
+        assert.isFalse(isVisible(element.$.editControls.parentElement));
+      });
     });
   });
 </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 3cccbca..41d3037 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
@@ -18,18 +18,20 @@
 <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="../../../styles/shared-styles.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="../../edit/gr-edit-file-controls/gr-edit-file-controls.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-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">
+<link rel="import" href="../gr-file-list-constants.html">
 
 <dom-module id="gr-file-list">
   <template>
@@ -50,6 +52,12 @@
       :host(.editLoaded) .hideOnEdit {
         display: none;
       }
+      .showOnEdit {
+        display: none;
+      }
+      :host(.editLoaded) .showOnEdit {
+        display: initial;
+      }
       .reviewed,
       .status {
         align-items: center;
@@ -181,6 +189,10 @@
         display: initial;
         opacity: 100;
       }
+      .editFileControls {
+        margin-left: 1em;
+        width: 4em;
+      }
       @media screen and (max-width: 50em) {
         .desktop {
           display: none;
@@ -296,6 +308,12 @@
               <span class="markReviewed" title="Mark as reviewed (shortcut: r)">[[_computeReviewedText(file.isReviewed)]]</span>
             </label>
           </div>
+          <div class="editFileControls showOnEdit">
+            <gr-edit-file-controls
+                class$="[[_computeClass('', file.__path)]]"
+                file-path="[[file.__path]]"
+                on-edit-tap="_handleEditTap"></gr-edit-file-controls>
+          </div>
         </div>
         <template is="dom-if"
             if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
@@ -335,6 +353,7 @@
       </div>
       <!-- Empty div here exists to keep spacing in sync with file rows. -->
       <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]" hidden></div>
+      <div class="editFileControls showOnEdit"></div>
     </div>
     <div
         class="row totalChanges"
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 b1a321e..8ed6759 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
@@ -61,6 +61,11 @@
         type: Boolean,
         observer: '_editLoadedChanged',
       },
+      filesExpanded: {
+        type: String,
+        value: GrFileListConstants.FilesExpandedState.NONE,
+        notify: true,
+      },
       _files: {
         type: Array,
         observer: '_filesChanged',
@@ -301,6 +306,8 @@
     collapseAllDiffs() {
       this._showInlineDiffs = false;
       this._expandedFilePaths = [];
+      this.filesExpanded = this._computeExpandedFiles(
+          this._expandedFilePaths.length, this._files.length);
       this.$.diffCursor.handleDiffUpdate();
     },
 
@@ -426,12 +433,7 @@
 
     _getFiles() {
       return this.$.restAPI.getChangeFilesAsSpeciallySortedArray(
-          this.changeNum, this.patchRange).then(files => {
-            // Append UI-specific properties.
-            return files.map(file => {
-              return file;
-            });
-          });
+          this.changeNum, this.patchRange);
     },
 
     /**
@@ -815,6 +817,15 @@
           detail.path);
     },
 
+    _computeExpandedFiles(expandedCount, totalCount) {
+      if (expandedCount === 0) {
+        return GrFileListConstants.FilesExpandedState.NONE;
+      } else if (expandedCount === totalCount) {
+        return GrFileListConstants.FilesExpandedState.ALL;
+      }
+      return GrFileListConstants.FilesExpandedState.SOME;
+    },
+
     /**
      * Handle splices to the list of expanded file paths. If there are any new
      * entries in the expanded list, then render each diff corresponding in
@@ -825,6 +836,9 @@
     _expandedPathsChanged(record) {
       if (!record) { return; }
 
+      this.filesExpanded = this._computeExpandedFiles(
+          this._expandedFilePaths.length, this._files.length);
+
       // Find the paths introduced by the new index splices:
       const newPaths = record.indexSplices
           .map(splice => {
@@ -922,5 +936,10 @@
     _computeReviewedText(isReviewed) {
       return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
     },
+
+    _handleEditTap(e) {
+      const url = Gerrit.Nav.getEditUrlForDiff(this.change, e.detail.path);
+      Gerrit.Nav.navigateToRelativeUrl(url);
+    },
   });
 })();
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 2e91d80..6933b78 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
@@ -700,7 +700,6 @@
       assert.isTrue(element._updateDiffPreferences.called);
     });
 
-
     test('expanded attribute not set on path when not expanded', () => {
       element._files = [
         {__path: '/COMMIT_MSG'},
@@ -791,6 +790,29 @@
       element.push('_expandedFilePaths', path);
     });
 
+    test('filesExpanded value updates to correct enum', () => {
+      element._files = [{__path: 'foo.bar'}, {__path: 'baz.bar'}];
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.NONE);
+      element.push('_expandedFilePaths', 'baz.bar');
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.SOME);
+      element.push('_expandedFilePaths', 'foo.bar');
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.ALL);
+      element.collapseAllDiffs();
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.NONE);
+      element.expandAllDiffs();
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.ALL);
+    });
+
     suite('_handleFileListTap', () => {
       function testForModifier(modifier) {
         const e = {preventDefault() {}};
@@ -1237,6 +1259,20 @@
         });
       });
     });
+
+    test('editing actions', () => {
+      element.editLoaded = true;
+      element.change = {_number: '42', project: 'test'};
+      const navStub = sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
+      const editControls =
+          Polymer.dom(element.root).querySelectorAll('.row:not(.header)')
+            .map(row => row.querySelector('gr-edit-file-controls'));
+
+      // Commit message should not have edit controls.
+      assert.isTrue(editControls[0].classList.contains('invisible'));
+      MockInteractions.tap(editControls[1].$.edit);
+      assert.isTrue(navStub.called);
+    });
   });
   a11ySuite('basic');
 </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 f27c7e9..593b932 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
@@ -56,6 +56,13 @@
         padding: .5em 1.5em;
         width: 100%;
       }
+      .actions {
+        display: flex;
+        justify-content: space-between;
+      }
+      .actions gr-button {
+        margin-left: 1em;
+      }
       .peopleContainer,
       .labelsContainer {
         flex-shrink: 0;
@@ -137,9 +144,6 @@
       #savingLabel.saving {
         display: inline;
       }
-      #cancelButton {
-        float: right;
-      }
       @media screen and (max-width: 50em) {
         :host {
           max-height: none;
@@ -261,33 +265,40 @@
           Saving comments...
         </span>
       </section>
-      <section>
-        <gr-button
-            primary
-            disabled="[[_computeSendButtonDisabled(knownLatestState, _sendButtonLabel, diffDrafts, draft, _reviewersMutated, _labelsChanged, _includeComments)]]"
-            class="action send"
-            on-tap="_sendTapHandler">[[_sendButtonLabel]]</gr-button>
-        <template is="dom-if" if="[[canBeStarted]]">
+      <section class="actions">
+        <div class="left">
+          <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
-              disabled="[[_isState(knownLatestState, 'not-latest')]]"
-              class="action save"
-              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>
-        <gr-button
-            id="cancelButton"
-            class="action cancel"
-            on-tap="_cancelTapHandler">Cancel</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"
+              on-tap="_sendTapHandler">[[_sendButtonLabel]]</gr-button>
+          <template is="dom-if" if="[[canBeStarted]]">
+            <gr-button
+                link
+                disabled="[[_isState(knownLatestState, 'not-latest')]]"
+                class="action save"
+                on-tap="_saveTapHandler">Save</gr-button>
+          </template>
+        </div>
       </section>
     </div>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
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 85db3a1..0fb9df2 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
@@ -24,11 +24,8 @@
 <dom-module id="gr-account-dropdown">
   <template>
     <style include="shared-styles">
-      button {
-        background: none;
-        border: none;
-        font: inherit;
-        padding: .3em 0;
+      gr-dropdown {
+        padding: .5em;
       }
       gr-avatar {
         height: 2em;
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 9f5d39d..5a83a89 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
@@ -57,6 +57,7 @@
       }
       ul {
         list-style: none;
+        padding-left: 1em;
       }
       .links > li {
         cursor: default;
@@ -89,10 +90,10 @@
         max-width: 500px;
       }
       gr-dropdown {
-        padding: 0.5em;
+        padding: 1em .5em;
       }
       .browse {
-        padding: 1em;
+        margin: .5em;
         text-decoration: none;
       }
       .accountContainer:not(.loggedIn):not(.loggedOut) .loginButton,
@@ -104,7 +105,7 @@
       .accountContainer {
         align-items: center;
         display: flex;
-        margin: 0 -0.5em 0 0.5em;
+        margin: 0 -.5em 0 .5em;
         white-space: nowrap;
         overflow: hidden;
         text-overflow: ellipsis;
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
index b03f2e5..8453da9 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -57,6 +57,7 @@
       console.warn('Use of uninitialized routing');
     };
 
+    const EDIT_PATCHNUM = 'edit';
     const PARENT_PATCHNUM = 'PARENT';
 
     window.Gerrit.Nav = {
@@ -277,6 +278,7 @@
           changeNum,
           project,
           path,
+          patchNum: EDIT_PATCHNUM,
         });
       },
 
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 5b9f4f6..89ca50f 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -87,8 +87,7 @@
     PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
     PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',
 
-    QUERY: '/q/:query',
-    QUERY_OFFSET: '/q/:query,:offset',
+    QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
 
     // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
     CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
@@ -126,6 +125,16 @@
    */
   const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;
 
+  /**
+   * Pattern to recognize '+' in url-encoded strings for replacement with ' '.
+   */
+  const PLUS_PATTERN = /\+/g;
+
+  /**
+   * Pattern to recognize leading '?' in window.location.search, for stripping.
+   */
+  const QUESTION_PATTERN = /^\?*/;
+
   // Polymer makes `app` intrinsically defined on the window by virtue of the
   // custom element having the id "app", but it is made explicit here.
   const app = document.querySelector('#app');
@@ -178,70 +187,23 @@
       page.redirect(url);
     },
 
+    /**
+     * @param {!Object} params
+     * @return {string}
+     */
     _generateUrl(params) {
       const base = this.getBaseUrl();
       let url = '';
+      const Views = Gerrit.Nav.View;
 
-      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';
-        }
+      if (params.view === Views.SEARCH) {
+        url = this._generateSearchUrl(params);
+      } else if (params.view === Views.CHANGE) {
+        url = this._generateChangeUrl(params);
+      } else if (params.view === Views.DASHBOARD) {
+        url = this._generateDashboardUrl(params);
+      } else if (params.view === Views.DIFF || params.view === Views.EDIT) {
+        url = this._generateDiffOrEditUrl(params);
       } else {
         throw new Error('Can\'t generate');
       }
@@ -250,6 +212,108 @@
     },
 
     /**
+     * @param {!Object} params
+     * @return {string}
+     */
+    _generateSearchUrl(params) {
+      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 ') +
+              ')');
+        }
+      }
+      return '/q/' + operators.join('+');
+    },
+
+    /**
+     * @param {!Object} params
+     * @return {string}
+     */
+    _generateChangeUrl(params) {
+      let range = this._getPatchRangeExpression(params);
+      if (range.length) { range = '/' + range; }
+      let suffix = `${range}`;
+      if (params.querystring) {
+        suffix += '?' + params.querystring;
+      }
+      if (params.project) {
+        return `/c/${params.project}/+/${params.changeNum}${suffix}`;
+      } else {
+        return `/c/${params.changeNum}${suffix}`;
+      }
+    },
+
+    /**
+     * @param {!Object} params
+     * @return {string}
+     */
+    _generateDashboardUrl(params) {
+      if (params.sections) {
+        // Custom dashboard.
+        const queryParams = params.sections.map(section => {
+          return encodeURIComponent(section.name) + '=' +
+              encodeURIComponent(section.query);
+        });
+        if (params.title) {
+          queryParams.push('title=' + encodeURIComponent(params.title));
+        }
+        const user = params.user ? params.user : '';
+        return `/dashboard/${user}?${queryParams.join('&')}`;
+      } else {
+        // User dashboard.
+        return `/dashboard/${params.user || 'self'}`;
+      }
+    },
+
+    /**
+     * @param {!Object} params
+     * @return {string}
+     */
+    _generateDiffOrEditUrl(params) {
+      let range = this._getPatchRangeExpression(params);
+      if (range.length) { range = '/' + range; }
+
+      let suffix = `${range}/${this.encodeURL(params.path, true)}`;
+
+      if (params.view === Gerrit.Nav.View.EDIT) { suffix += ',edit'; }
+
+      if (params.lineNum) {
+        suffix += '#';
+        if (params.leftSide) { suffix += 'b'; }
+        suffix += params.lineNum;
+      }
+
+      if (params.project) {
+        return `/c/${params.project}/+/${params.changeNum}${suffix}`;
+      } else {
+        return `/c/${params.changeNum}${suffix}`;
+      }
+    },
+
+    /**
      * 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.
@@ -517,7 +581,6 @@
       this._mapRoute(RoutePattern.ADMIN_PLACEHOLDER,
           '_handleAdminPlaceholderRoute', true);
 
-      this._mapRoute(RoutePattern.QUERY_OFFSET, '_handleQueryRoute');
       this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
 
       this._mapRoute(RoutePattern.CHANGE_NUMBER_LEGACY,
@@ -593,19 +656,101 @@
       });
     },
 
-    _handleDashboardRoute(data) {
-      if (!data.params[0]) {
-        this._redirect('/dashboard/self');
-        return;
+    /**
+     * Decode an application/x-www-form-urlencoded string.
+     *
+     * @param {string} qs The application/x-www-form-urlencoded string.
+     * @return {string} The decoded string.
+     */
+    _decodeQueryString(qs) {
+      return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
+    },
+
+    /**
+     * Parse a query string (e.g. window.location.search) into an array of
+     * name/value pairs.
+     *
+     * @param {string} qs The application/x-www-form-urlencoded query string.
+     * @return {!Array<!Array<string>>} An array of name/value pairs, where each
+     *     element is a 2-element array.
+     */
+    _parseQueryString(qs) {
+      qs = qs.replace(QUESTION_PATTERN, '');
+      if (!qs) {
+        return [];
+      }
+      const params = [];
+      qs.split('&').forEach(param => {
+        const idx = param.indexOf('=');
+        let name;
+        let value;
+        if (idx < 0) {
+          name = this._decodeQueryString(param);
+          value = '';
+        } else {
+          name = this._decodeQueryString(param.substring(0, idx));
+          value = this._decodeQueryString(param.substring(idx + 1));
+        }
+        if (name) {
+          params.push([name, value]);
+        }
+      });
+      return params;
+    },
+
+    /**
+     * Handle dashboard routes. These may be user, custom, or project
+     * dashboards.
+     *
+     * @param {!Object} data The parsed route data.
+     * @param {string=} opt_qs Optional query string associated with the route.
+     *     If not given, window.location.search is used. (Used by tests).
+     */
+    _handleDashboardRoute(data, opt_qs) {
+      // opt_qs may be provided by a test, and it may have a falsy value
+      const qs = opt_qs !== undefined ? opt_qs : window.location.search;
+      const queryParams = this._parseQueryString(qs);
+      let title = 'Custom Dashboard';
+      const titleParam = queryParams.find(
+          elem => elem[0].toLowerCase() === 'title');
+      if (titleParam) {
+        title = titleParam[1];
+      }
+      const sectionParams = queryParams.filter(
+          elem => elem[0] && elem[1] && elem[0].toLowerCase() !== 'title');
+      const sections = sectionParams.map(elem => {
+        return {
+          name: elem[0],
+          query: elem[1],
+        };
+      });
+
+      if (sections.length > 0) {
+        // Custom dashboard view.
+        this._setParams({
+          view: Gerrit.Nav.View.DASHBOARD,
+          user: data.params[0] || 'self',
+          sections,
+          title,
+        });
+        return Promise.resolve();
       }
 
+      if (!data.params[0] && sections.length === 0) {
+        // Redirect /dashboard/ -> /dashboard/self.
+        this._redirect('/dashboard/self');
+        return Promise.resolve();
+      }
+
+      // User dashboard. We require viewing user to be logged in, else we
+      // redirect to login for self dashboard or simple owner search for
+      // other user dashboard.
       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]);
+            this._redirect('/q/owner:' + encodeURIComponent(data.params[0]));
           }
         } else {
           this._setParams({
@@ -842,8 +987,11 @@
     },
 
     _handleQueryRoute(data) {
-      data.params.view = Gerrit.Nav.View.SEARCH;
-      this._setParams(data.params);
+      this._setParams({
+        view: Gerrit.Nav.View.SEARCH,
+        query: data.params[0],
+        offset: data.params[2],
+      });
     },
 
     _handleChangeNumberLegacyRoute(ctx) {
@@ -881,6 +1029,7 @@
         basePatchNum: ctx.params[3],
         patchNum: ctx.params[5],
         view: Gerrit.Nav.View.CHANGE,
+        querystring: ctx.querystring,
       };
 
       this._normalizeLegacyRouteParams(params);
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
index ca00e19..b4fec5b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -237,13 +237,28 @@
           changeNum: '1234',
           project: 'test',
         };
+        const paramsWithQuery = {
+          view: Gerrit.Nav.View.CHANGE,
+          changeNum: '1234',
+          project: 'test',
+          querystring: 'revert&foo=bar',
+        };
+
         assert.equal(element._generateUrl(params), '/c/test/+/1234');
+        assert.equal(element._generateUrl(paramsWithQuery),
+            '/c/test/+/1234?revert&foo=bar');
 
         params.patchNum = 10;
         assert.equal(element._generateUrl(params), '/c/test/+/1234/10');
+        paramsWithQuery.patchNum = 10;
+        assert.equal(element._generateUrl(paramsWithQuery),
+            '/c/test/+/1234/10?revert&foo=bar');
 
         params.basePatchNum = 5;
         assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10');
+        paramsWithQuery.basePatchNum = 5;
+        assert.equal(element._generateUrl(paramsWithQuery),
+            '/c/test/+/1234/5..10?revert&foo=bar');
       });
 
       test('diff', () => {
@@ -280,6 +295,17 @@
             '/c/test/+/42/2/file.cpp#b123');
       });
 
+      test('edit', () => {
+        const params = {
+          view: Gerrit.Nav.View.EDIT,
+          changeNum: '42',
+          project: 'test',
+          path: 'x+y/path.cpp',
+        };
+        assert.equal(element._generateUrl(params),
+            '/c/test/+/42/x%252By/path.cpp,edit');
+      });
+
       test('_getPatchRangeExpression', () => {
         const params = {};
         let actual = element._getPatchRangeExpression(params);
@@ -297,6 +323,48 @@
         actual = element._getPatchRangeExpression(params);
         assert.equal(actual, '2..');
       });
+
+      suite('dashboard', () => {
+        test('self dashboard', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+          };
+          assert.equal(element._generateUrl(params), '/dashboard/self');
+        });
+
+        test('user dashboard', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+            user: 'user',
+          };
+          assert.equal(element._generateUrl(params), '/dashboard/user');
+        });
+
+        test('custom self dashboard, no title', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+            sections: [
+              {name: 'section 1', query: 'query 1'},
+              {name: 'section 2', query: 'query 2'},
+            ],
+          };
+          assert.equal(
+              element._generateUrl(params),
+              '/dashboard/?section%201=query%201&section%202=query%202');
+        });
+
+        test('custom user dashboard, with title', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+            user: 'user',
+            sections: [{name: 'name', query: 'query'}],
+            title: 'custom dashboard',
+          };
+          assert.equal(
+              element._generateUrl(params),
+              '/dashboard/user?name=query&title=custom%20dashboard');
+        });
+      });
     });
 
     suite('param normalization', () => {
@@ -478,6 +546,22 @@
             '/c/test/+/42#foo');
       });
 
+      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']};
@@ -514,7 +598,7 @@
           assert.isFalse(redirectStub.called);
         });
 
-        test('redirects to dahsboard if logged in', () => {
+        test('redirects to dashboard if logged in', () => {
           sandbox.stub(element.$.restAPI, 'getLoggedIn')
               .returns(Promise.resolve(true));
           const data = {
@@ -625,35 +709,31 @@
         });
 
         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');
+          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+          return element._handleDashboardRoute(data, '').then(() => {
+            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', () => {
+        test('own dashboard 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(() => {
+          const data = {canonicalPath: '/dashboard/', params: {0: 'seLF'}};
+          return element._handleDashboardRoute(data, '').then(() => {
             assert.isTrue(redirectToLoginStub.calledOnce);
             assert.isFalse(redirectStub.called);
             assert.isFalse(setParamsStub.called);
           });
         });
 
-        test('non-self dahsboard but signed out does not redirect', () => {
+        test('non-self dashboard 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(() => {
+          const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
+          return element._handleDashboardRoute(data, '').then(() => {
             assert.isFalse(redirectToLoginStub.called);
             assert.isFalse(setParamsStub.called);
             assert.isTrue(redirectStub.calledOnce);
@@ -661,13 +741,11 @@
           });
         });
 
-        test('dahsboard while signed in sets params', () => {
+        test('dashboard 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(() => {
+          const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
+          return element._handleDashboardRoute(data, '').then(() => {
             assert.isFalse(redirectToLoginStub.called);
             assert.isFalse(redirectStub.called);
             assert.isTrue(setParamsStub.calledOnce);
@@ -677,6 +755,42 @@
             });
           });
         });
+
+        test('custom dashboard without title', () => {
+          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+          return element._handleDashboardRoute(data, '?a=b&c&d=e').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: 'self',
+              sections: [
+                {name: 'a', query: 'b'},
+                {name: 'd', query: 'e'},
+              ],
+              title: 'Custom Dashboard',
+            });
+          });
+        });
+
+        test('custom dashboard with title', () => {
+          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+          return element._handleDashboardRoute(data, '?a=b&c&d=&=e&title=t')
+              .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: 'self',
+                  sections: [
+                    {name: 'a', query: 'b'},
+                  ],
+                  title: 't',
+                });
+              });
+        });
       });
 
       suite('group routes', () => {
@@ -1020,6 +1134,7 @@
               null, // 4 Unused
               9, // 5 Patch number
             ],
+            querystring: '',
           };
           element._handleChangeLegacyRoute(ctx);
           assert.isTrue(normalizeRouteStub.calledOnce);
@@ -1028,6 +1143,7 @@
             basePatchNum: 6,
             patchNum: 9,
             view: Gerrit.Nav.View.CHANGE,
+            querystring: '',
           });
         });
 
@@ -1170,5 +1286,31 @@
         });
       });
     });
+
+    suite('_parseQueryString', () => {
+      test('empty queries', () => {
+        assert.deepEqual(element._parseQueryString(''), []);
+        assert.deepEqual(element._parseQueryString('?'), []);
+        assert.deepEqual(element._parseQueryString('??'), []);
+        assert.deepEqual(element._parseQueryString('&&&'), []);
+      });
+
+      test('url decoding', () => {
+        assert.deepEqual(element._parseQueryString('+'), [[' ', '']]);
+        assert.deepEqual(element._parseQueryString('???+%3d+'), [[' = ', '']]);
+        assert.deepEqual(
+            element._parseQueryString('%6e%61%6d%65=%76%61%6c%75%65'),
+            [['name', 'value']]);
+      });
+
+      test('multiple parameters', () => {
+        assert.deepEqual(
+            element._parseQueryString('a=b&c=d&e=f'),
+            [['a', 'b'], ['c', 'd'], ['e', 'f']]);
+        assert.deepEqual(
+            element._parseQueryString('&a=b&&&e=f&'),
+            [['a', 'b'], ['e', 'f']]);
+      });
+    });
   });
 </script>
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 bfd8e90..ca3cf62 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
@@ -26,6 +26,7 @@
 
         // NOTE: intended singleton.
         value: {
+          configured: false,
           loading: false,
           callbacks: [],
         },
@@ -60,12 +61,13 @@
     },
 
     _getHighlightLib() {
-      return window.hljs;
-    },
+      const lib = window.hljs;
+      if (lib && !this._state.configured) {
+        this._state.configured = true;
 
-    _configureHighlightLib() {
-      this._getHighlightLib().configure(
-          {classPrefix: 'gr-diff gr-syntax gr-syntax-'});
+        lib.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
+      }
+      return lib;
     },
 
     _getLibRoot() {
@@ -93,10 +95,8 @@
         }
 
         script.src = src;
-        script.onload = function() {
-          this._configureHighlightLib();
-          resolve();
-        }.bind(this);
+        script.onload = resolve;
+        script.onerror = reject;
         Polymer.dom(document.head).appendChild(script);
       });
     },
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 6ddde46..6e88ed1 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
@@ -55,6 +55,7 @@
       loadStub.restore();
 
       // Because the element state is a singleton, clean it up.
+      element._state.configured = false;
       element._state.loading = false;
       element._state.callbacks = [];
     });
@@ -88,8 +89,13 @@
     });
 
     suite('preloaded', () => {
+      let hljsStub;
+
       setup(() => {
-        window.hljs = 'test-object';
+        hljsStub = {
+          configure: sinon.stub(),
+        };
+        window.hljs = hljsStub;
       });
 
       teardown(() => {
@@ -101,7 +107,14 @@
         element.get().then(firstCallHandler);
         flush(() => {
           assert.isTrue(firstCallHandler.called);
-          assert.isTrue(firstCallHandler.calledWith('test-object'));
+          assert.isTrue(firstCallHandler.calledWith(hljsStub));
+          done();
+        });
+      });
+
+      test('configures hljs', done => {
+        element.get().then(() => {
+          assert.isTrue(window.hljs.configure.calledOnce);
           done();
         });
       });
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.html b/polygerrit-ui/app/elements/edit/gr-edit-constants.html
new file mode 100644
index 0000000..1941dc8
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-constants.html
@@ -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.
+-->
+<script>
+  (function(window) {
+    'use strict';
+
+    const GrEditConstants = window.GrEditConstants || {};
+
+    GrEditConstants.Actions = {
+      EDIT: {label: 'Edit', id: 'edit'},
+      DELETE: {label: 'Delete', id: 'delete'},
+      RENAME: {label: 'Rename', id: 'rename'},
+      RESTORE: {label: 'Restore', id: 'restore'},
+    };
+
+    window.GrEditConstants = GrEditConstants;
+  })(window);
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
new file mode 100644
index 0000000..9eb4974
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
@@ -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.
+-->
+
+<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="../../../bower_components/iron-input/iron-input.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-dropdown/gr-dropdown.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-edit-constants.html">
+
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-edit-controls">
+  <template>
+    <style include="shared-styles">
+      :host {
+        align-items: center;
+        display: flex;
+        justify-content: flex-end;
+      }
+      .invisible {
+        display: none;
+      }
+      gr-button {
+        margin-left: 1em;
+        text-decoration: none;
+      }
+      gr-confirm-dialog {
+        width: 50em;
+      }
+      gr-confirm-dialog .main {
+        width: 100%;
+      }
+      gr-autocomplete {
+        --gr-autocomplete: {
+          border: 1px solid #d1d2d3;
+          border-radius: 2px;
+          font-size: 1em;
+          height: 2em;
+          padding: 0 .15em;
+        }
+      }
+      input {
+        border: 1px solid #d1d2d3;
+        border-radius: 2px;
+        font-size: 1em;
+        height: 2em;
+        margin: .5em 0;
+        padding: 0 .15em;
+        width: 100%;
+      }
+    </style>
+    <template is="dom-repeat" items="[[_actions]]" as="action">
+      <gr-button
+          id$="[[action.id]]"
+          class$="[[_computeIsInvisible(action.id, hiddenActions)]]"
+          link
+          on-tap="_handleTap">[[action.label]]</gr-button>
+    </template>
+    <gr-overlay id="overlay" with-backdrop>
+      <gr-confirm-dialog
+          id="editDialog"
+          class="invisible dialog"
+          disabled$="[[!_isValidPath(_path)]]"
+          confirm-label="Edit"
+          on-confirm="_handleEditConfirm"
+          on-cancel="_handleDialogCancel">
+        <div class="header">Edit a file</div>
+        <div class="main">
+          <gr-autocomplete
+              placeholder="Enter an existing or new full file path."
+              query="[[_query]]"
+              text="{{_path}}"></gr-autocomplete>
+        </div>
+      </gr-confirm-dialog>
+      <gr-confirm-dialog
+          id="deleteDialog"
+          class="invisible dialog"
+          disabled$="[[!_isValidPath(_path)]]"
+          confirm-label="Delete"
+          on-confirm="_handleDeleteConfirm"
+          on-cancel="_handleDialogCancel">
+        <div class="header">Delete a file</div>
+        <div class="main">
+          <gr-autocomplete
+              placeholder="Enter an existing full file path."
+              query="[[_query]]"
+              text="{{_path}}"></gr-autocomplete>
+        </div>
+      </gr-confirm-dialog>
+      <gr-confirm-dialog
+          id="renameDialog"
+          class="invisible dialog"
+          disabled$="[[!_computeRenameDisabled(_path, _newPath)]]"
+          confirm-label="Rename"
+          on-confirm="_handleRenameConfirm"
+          on-cancel="_handleDialogCancel">
+        <div class="header">Rename a file</div>
+        <div class="main">
+          <gr-autocomplete
+              placeholder="Enter an existing full file path."
+              query="[[_query]]"
+              text="{{_path}}"></gr-autocomplete>
+          <input
+              class="newPathInput"
+              is="iron-input"
+              bind-value="{{_newPath}}"
+              placeholder="Enter the new path."/>
+        </div>
+      </gr-confirm-dialog>
+      <gr-confirm-dialog
+          id="restoreDialog"
+          class="invisible dialog"
+          confirm-label="Restore"
+          on-confirm="_handleRestoreConfirm"
+          on-cancel="_handleDialogCancel">
+        <div class="header">Restore this file?</div>
+        <div class="main">
+          <input
+              is="iron-input"
+              disabled
+              bind-value="{{_path}}"/>
+        </div>
+      </gr-confirm-dialog>
+    </gr-overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-edit-controls.js"></script>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
new file mode 100644
index 0000000..c04df74
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
@@ -0,0 +1,193 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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-edit-controls',
+    properties: {
+      change: Object,
+      /**
+       * TODO(kaspern): by default, the RESTORE action should be hidden in the
+       * file-list as it is a per-file action only. Remove this default value
+       * when the Actions dictionary is moved to a shared constants file and
+       * use the hiddenActions property in the parent component.
+       */
+      hiddenActions: {
+        type: Array,
+        value() { return [GrEditConstants.Actions.RESTORE.id]; },
+      },
+
+      _actions: {
+        type: Array,
+        value() { return Object.values(GrEditConstants.Actions); },
+      },
+      _path: {
+        type: String,
+        value: '',
+      },
+      _newPath: {
+        type: String,
+        value: '',
+      },
+      _query: {
+        type: Function,
+        value() {
+          return this._queryFiles.bind(this);
+        },
+      },
+    },
+
+    behaviors: [
+      Gerrit.PatchSetBehavior,
+    ],
+
+    _handleTap(e) {
+      e.preventDefault();
+      const action = Polymer.dom(e).localTarget.id;
+      switch (action) {
+        case GrEditConstants.Actions.EDIT.id:
+          this.openEditDialog();
+          return;
+        case GrEditConstants.Actions.DELETE.id:
+          this.openDeleteDialog();
+          return;
+        case GrEditConstants.Actions.RENAME.id:
+          this.openRenameDialog();
+          return;
+        case GrEditConstants.Actions.RESTORE.id:
+          this.openRestoreDialog();
+          return;
+      }
+    },
+
+    openEditDialog(opt_path) {
+      if (opt_path) { this._path = opt_path; }
+      return this._showDialog(this.$.editDialog);
+    },
+
+    openDeleteDialog(opt_path) {
+      if (opt_path) { this._path = opt_path; }
+      return this._showDialog(this.$.deleteDialog);
+    },
+
+    openRenameDialog(opt_path) {
+      if (opt_path) { this._path = opt_path; }
+      return this._showDialog(this.$.renameDialog);
+    },
+
+    openRestoreDialog(opt_path) {
+      if (opt_path) { this._path = opt_path; }
+      return this._showDialog(this.$.restoreDialog);
+    },
+
+    /**
+     * Given a path string, checks that it is a valid file path.
+     * @param {string} path
+     * @return {boolean}
+     */
+    _isValidPath(path) {
+      // Double negation needed for strict boolean return type.
+      return !!path.length && !path.endsWith('/');
+    },
+
+    _computeRenameDisabled(path, newPath) {
+      return this._isValidPath(path) && this._isValidPath(newPath);
+    },
+
+    /**
+     * Given a dom event, gets the dialog that lies along this event path.
+     * @param {!Event} e
+     * @return {!Element}
+     */
+    _getDialogFromEvent(e) {
+      return Polymer.dom(e).path.find(element => {
+        if (!element.classList) { return false; }
+        return element.classList.contains('dialog');
+      });
+    },
+
+    _showDialog(dialog) {
+      return this.$.overlay.open().then(() => {
+        dialog.classList.toggle('invisible', false);
+        const autocomplete = dialog.querySelector('gr-autocomplete');
+        if (autocomplete) { autocomplete.focus(); }
+        this.async(() => { this.$.overlay.center(); }, 1);
+      });
+    },
+
+    _closeDialog(dialog, clearInputs) {
+      if (clearInputs) {
+        // Dialog may have autocompletes and plain inputs -- as these have
+        // different properties representing their bound text, it is easier to
+        // just make two separate queries.
+        dialog.querySelectorAll('gr-autocomplete')
+            .forEach(input => { input.text = ''; });
+        dialog.querySelectorAll('input')
+            .forEach(input => { input.bindValue = ''; });
+      }
+
+      dialog.classList.toggle('invisible', true);
+      return this.$.overlay.close();
+    },
+
+    _handleDialogCancel(e) {
+      this._closeDialog(this._getDialogFromEvent(e));
+    },
+
+    _handleEditConfirm(e) {
+      const url = Gerrit.Nav.getEditUrlForDiff(this.change, this._path);
+      Gerrit.Nav.navigateToRelativeUrl(url);
+      this._closeDialog(this._getDialogFromEvent(e), true);
+    },
+
+    _handleDeleteConfirm(e) {
+      this.$.restAPI.deleteFileInChangeEdit(this.change._number, this._path)
+          .then(res => {
+            if (!res.ok) { return; }
+            this._closeDialog(this._getDialogFromEvent(e), true);
+            Gerrit.Nav.navigateToChange(this.change);
+          });
+    },
+
+    _handleRestoreConfirm(e) {
+      this.$.restAPI.restoreFileInChangeEdit(this.change._number, this._path)
+          .then(res => {
+            if (!res.ok) { return; }
+            this._closeDialog(this._getDialogFromEvent(e), true);
+            Gerrit.Nav.navigateToChange(this.change);
+          });
+    },
+
+    _handleRenameConfirm(e) {
+      return this.$.restAPI.renameFileInChangeEdit(this.change._number,
+          this._path, this._newPath).then(res => {
+            if (!res.ok) { return; }
+            this._closeDialog(this._getDialogFromEvent(e), true);
+            Gerrit.Nav.navigateToChange(this.change);
+          });
+    },
+
+    _queryFiles(input) {
+      return this.$.restAPI.queryChangeFiles(this.change._number,
+          this.EDIT_NAME, input).then(res => res.map(file => {
+            return {name: file};
+          }));
+    },
+
+    _computeIsInvisible(id, hiddenActions) {
+      return hiddenActions.includes(id) ? 'invisible' : '';
+    },
+  });
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
new file mode 100644
index 0000000..7f2f9bb
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
@@ -0,0 +1,349 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT 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-edit-controls</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-edit-controls.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-edit-controls></gr-edit-controls>
+  </template>
+</test-fixture>
+
+<script>
+suite('gr-edit-controls tests', () => {
+  let element;
+  let sandbox;
+  let showDialogSpy;
+  let closeDialogSpy;
+  let queryStub;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.change = {_number: '42'};
+    showDialogSpy = sandbox.spy(element, '_showDialog');
+    closeDialogSpy = sandbox.spy(element, '_closeDialog');
+    queryStub = sandbox.stub(element.$.restAPI, 'queryChangeFiles')
+        .returns(Promise.resolve([]));
+    flushAsynchronousOperations();
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('all actions exist', () => {
+    assert.equal(Polymer.dom(element.root).querySelectorAll('gr-button').length,
+        element._actions.length);
+  });
+
+  suite('edit button CUJ', () => {
+    let navStubs;
+
+    setup(() => {
+      navStubs = [
+        sandbox.stub(Gerrit.Nav, 'getEditUrlForDiff'),
+        sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl'),
+      ];
+    });
+
+    test('_isValidPath', () => {
+      assert.isFalse(element._isValidPath(''));
+      assert.isFalse(element._isValidPath('test/'));
+      assert.isFalse(element._isValidPath('/'));
+      assert.isTrue(element._isValidPath('test/path.cpp'));
+      assert.isTrue(element._isValidPath('test.js'));
+    });
+
+    test('edit', () => {
+      MockInteractions.tap(element.$$('#edit'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.editDialog.disabled);
+        assert.isFalse(queryStub.called);
+        element.$.editDialog.querySelector('gr-autocomplete').text =
+            'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isFalse(element.$.editDialog.disabled);
+        MockInteractions.tap(element.$.editDialog.$$('gr-button[primary]'));
+        for (const stub of navStubs) { assert.isTrue(stub.called); }
+        assert.isTrue(closeDialogSpy.called);
+      });
+    });
+
+    test('cancel', () => {
+      MockInteractions.tap(element.$$('#edit'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.editDialog.disabled);
+        element.$.editDialog.querySelector('gr-autocomplete').text =
+            'src/test.cpp';
+        assert.isFalse(element.$.editDialog.disabled);
+        MockInteractions.tap(element.$.editDialog.$$('gr-button'));
+        for (const stub of navStubs) { assert.isFalse(stub.called); }
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+      });
+    });
+  });
+
+  suite('delete button CUJ', () => {
+    let navStub;
+    let deleteStub;
+
+    setup(() => {
+      navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      deleteStub = sandbox.stub(element.$.restAPI, 'deleteFileInChangeEdit');
+    });
+
+    test('delete', () => {
+      deleteStub.returns(Promise.resolve({ok: true}));
+      MockInteractions.tap(element.$$('#delete'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.deleteDialog.disabled);
+        assert.isFalse(queryStub.called);
+        element.$.deleteDialog.querySelector('gr-autocomplete').text =
+            'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isFalse(element.$.deleteDialog.disabled);
+        MockInteractions.tap(element.$.deleteDialog.$$('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(deleteStub.called);
+
+        return deleteStub.lastCall.returnValue.then(() => {
+          assert.equal(element._path, '');
+          assert.isTrue(navStub.called);
+          assert.isTrue(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('delete fails', () => {
+      deleteStub.returns(Promise.resolve({ok: false}));
+      MockInteractions.tap(element.$$('#delete'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.deleteDialog.disabled);
+        assert.isFalse(queryStub.called);
+        element.$.deleteDialog.querySelector('gr-autocomplete').text =
+            'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isFalse(element.$.deleteDialog.disabled);
+        MockInteractions.tap(element.$.deleteDialog.$$('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(deleteStub.called);
+
+        return deleteStub.lastCall.returnValue.then(() => {
+          assert.isFalse(navStub.called);
+          assert.isFalse(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('cancel', () => {
+      MockInteractions.tap(element.$$('#delete'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.deleteDialog.disabled);
+        element.$.deleteDialog.querySelector('gr-autocomplete').text =
+            'src/test.cpp';
+        assert.isFalse(element.$.deleteDialog.disabled);
+        MockInteractions.tap(element.$.deleteDialog.$$('gr-button'));
+        assert.isFalse(navStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+      });
+    });
+  });
+
+  suite('rename button CUJ', () => {
+    let navStub;
+    let renameStub;
+
+    setup(() => {
+      navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      renameStub = sandbox.stub(element.$.restAPI, 'renameFileInChangeEdit');
+    });
+
+    test('rename', () => {
+      renameStub.returns(Promise.resolve({ok: true}));
+      MockInteractions.tap(element.$$('#rename'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.renameDialog.disabled);
+        assert.isFalse(queryStub.called);
+        element.$.renameDialog.querySelector('gr-autocomplete').text =
+            'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isTrue(element.$.renameDialog.disabled);
+
+        element.$.renameDialog.querySelector('.newPathInput').bindValue =
+            'src/test.newPath';
+
+        assert.isFalse(element.$.renameDialog.disabled);
+        MockInteractions.tap(element.$.renameDialog.$$('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(renameStub.called);
+
+        return renameStub.lastCall.returnValue.then(() => {
+          assert.equal(element._path, '');
+          assert.isTrue(navStub.called);
+          assert.isTrue(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('rename fails', () => {
+      renameStub.returns(Promise.resolve({ok: false}));
+      MockInteractions.tap(element.$$('#rename'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.renameDialog.disabled);
+        assert.isFalse(queryStub.called);
+        element.$.renameDialog.querySelector('gr-autocomplete').text =
+            'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isTrue(element.$.renameDialog.disabled);
+
+        element.$.renameDialog.querySelector('.newPathInput').bindValue =
+            'src/test.newPath';
+
+        assert.isFalse(element.$.renameDialog.disabled);
+        MockInteractions.tap(element.$.renameDialog.$$('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(renameStub.called);
+
+        return renameStub.lastCall.returnValue.then(() => {
+          assert.isFalse(navStub.called);
+          assert.isFalse(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('cancel', () => {
+      MockInteractions.tap(element.$$('#rename'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.renameDialog.disabled);
+        element.$.renameDialog.querySelector('gr-autocomplete').text =
+            'src/test.cpp';
+        element.$.renameDialog.querySelector('.newPathInput').bindValue =
+            'src/test.newPath';
+        assert.isFalse(element.$.renameDialog.disabled);
+        MockInteractions.tap(element.$.renameDialog.$$('gr-button'));
+        assert.isFalse(navStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+        assert.equal(element._newPath, 'src/test.newPath');
+      });
+    });
+  });
+
+  suite('restore button CUJ', () => {
+    let navStub;
+    let restoreStub;
+
+    setup(() => {
+      navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      restoreStub = sandbox.stub(element.$.restAPI, 'restoreFileInChangeEdit');
+    });
+
+    test('restore hidden by default', () => {
+      assert.isTrue(element.$$('#restore').classList.contains('invisible'));
+    });
+
+    test('restore', () => {
+      restoreStub.returns(Promise.resolve({ok: true}));
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(element.$$('#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(element.$.restoreDialog.$$('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(restoreStub.called);
+        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+        return restoreStub.lastCall.returnValue.then(() => {
+          assert.equal(element._path, '');
+          assert.isTrue(navStub.called);
+          assert.isTrue(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('restore fails', () => {
+      restoreStub.returns(Promise.resolve({ok: false}));
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(element.$$('#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(element.$.restoreDialog.$$('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(restoreStub.called);
+        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+        return restoreStub.lastCall.returnValue.then(() => {
+          assert.isFalse(navStub.called);
+          assert.isFalse(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('cancel', () => {
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(element.$$('#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(element.$.restoreDialog.$$('gr-button'));
+        assert.isFalse(navStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+      });
+    });
+  });
+
+  test('openEditDialog', () => {
+    return element.openEditDialog('test/path.cpp').then(() => {
+      assert.isFalse(element.$.editDialog.hasAttribute('hidden'));
+      assert.equal(element.$.editDialog.querySelector('gr-autocomplete').text,
+          'test/path.cpp');
+    });
+  });
+
+  test('_getDialogFromEvent', () => {
+    const spy = sandbox.spy(element, '_getDialogFromEvent');
+    element.addEventListener('tap', element._getDialogFromEvent);
+
+    MockInteractions.tap(element.$.editDialog);
+    flushAsynchronousOperations();
+    assert.equal(spy.lastCall.returnValue.id, 'editDialog');
+
+    MockInteractions.tap(element.$.deleteDialog);
+    flushAsynchronousOperations();
+    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
+
+    MockInteractions.tap(
+        element.$.deleteDialog.querySelector('gr-autocomplete'));
+    flushAsynchronousOperations();
+    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
+
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.notOk(spy.lastCall.returnValue);
+  });
+});
+</script>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
new file mode 100644
index 0000000..bfbe11a
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
@@ -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.
+-->
+
+<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="../../../styles/shared-styles.html">
+
+<dom-module id="gr-edit-file-controls">
+  <template>
+    <style include="shared-styles">
+      :host {
+        align-items: center;
+        display: flex;
+        justify-content: flex-end;
+      }
+      #edit {
+        margin-right: .5em;
+        text-decoration: none;
+      }
+    </style>
+    <gr-button
+        id="edit"
+        link
+        on-tap="_handleEditTap">Edit</gr-button>
+    <!-- TODO(kaspern): implement more menu. -->
+    <gr-dropdown
+        id="more"
+        hidden
+        link>More</gr-dropdown>
+  </template>
+  <script src="gr-edit-file-controls.js"></script>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
new file mode 100644
index 0000000..1c87621
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
@@ -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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-edit-file-controls',
+
+    /**
+     * Fired when the edit button is pressed.
+     *
+     * @event edit-tap
+     */
+
+    properties: {
+      filePath: String,
+    },
+
+    _handleEditTap() {
+      this.fire('edit-tap', {path: this.filePath});
+    },
+  });
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
new file mode 100644
index 0000000..250e208
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
@@ -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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-edit-file-controls</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-edit-file-controls.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-edit-file-controls></gr-edit-file-controls>
+  </template>
+</test-fixture>
+
+<script>
+suite('gr-edit-file-controls tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('edit tap emits event', () => {
+    const handler = sandbox.stub();
+    element.addEventListener('edit-tap', handler);
+    element.filePath = 'foo';
+
+    MockInteractions.tap(element.$.edit);
+    assert.isTrue(handler.called);
+    assert.equal(handler.lastCall.args[0].detail.path, 'foo');
+  });
+});
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
new file mode 100644
index 0000000..df2ac93
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
@@ -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.
+-->
+
+<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="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.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="../../../styles/shared-styles.html">
+
+
+<dom-module id="gr-editor-view">
+  <template>
+    <style include="shared-styles">
+      :host {
+        background-color: var(--view-background-color);
+      }
+      gr-fixed-panel {
+        background-color: #fff;
+        border-bottom: 1px #eee solid;
+        z-index: 10;
+      }
+      header,
+      .subHeader {
+        align-items: center;
+        display: flex;
+        justify-content: space-between;
+        padding: .75em var(--default-horizontal-margin);
+      }
+      header gr-editable-label {
+        font-size: 1.2em;
+        font-weight: bold;
+      }
+      .textareaWrapper {
+        margin: var(--default-horizontal-margin);
+      }
+      .textareaWrapper textarea {
+        border: 1px solid #ddd;
+        border-radius: 3px;
+        box-sizing: border-box;
+        font-family: var(--monospace-font-family);
+        min-height: 60vh;
+        resize: none;
+        white-space: pre;
+        width: 100%;
+      }
+      .textareaWrapper textarea:focus {
+        outline: none;
+      }
+      .textareaWrapper .editButtons {
+        display: none;
+      }
+      .rightControls {
+        justify-content: flex-end
+      }
+    </style>
+    <gr-fixed-panel
+        class$="[[_computeContainerClass(_editLoaded)]]"
+        floating-disabled="[[_panelFloatingDisabled]]"
+        keep-on-scroll
+        ready-for-measure="[[!_loading]]">
+      <header>
+        <gr-editable-label
+            label-text="File path"
+            value="[[_path]]"
+            placeholder="File path..."
+            on-changed="_handlePathChanged"></gr-editable-label>
+        <span class="rightControls">
+          <gr-button
+              id="save"
+              disabled$="[[_saveDisabled]]"
+              primary
+              on-tap="_saveEdit">Save</gr-button>
+          <gr-button id="cancel" on-tap="_handleCancelTap">Cancel</gr-button>
+        </span>
+      </header>
+    </gr-fixed-panel>
+    <div class="textareaWrapper">
+      <textarea value="{{_newContent::input}}" id="file"></textarea>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-editor-view.js"></script>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
new file mode 100644
index 0000000..5652793
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
@@ -0,0 +1,129 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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-editor-view',
+
+    /**
+     * Fired when the title of the page should change.
+     *
+     * @event title-change
+     */
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+
+      _change: Object,
+      _changeEditDetail: Object,
+      _changeNum: String,
+      _loggedIn: Boolean,
+      _path: String,
+      _content: String,
+      _newContent: String,
+      _saveDisabled: {
+        type: Boolean,
+        value: true,
+        computed: '_computeSaveDisabled(_content, _newContent)',
+      },
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.PatchSetBehavior,
+      Gerrit.PathListBehavior,
+    ],
+
+    attached() {
+      this._getLoggedIn().then(loggedIn => { this._loggedIn = loggedIn; });
+    },
+
+    _getLoggedIn() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _paramsChanged(value) {
+      if (value.view !== Gerrit.Nav.View.EDIT) { return; }
+
+      this._changeNum = value.changeNum;
+      this._path = value.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(() => {
+        const title = `Editing ${this.computeTruncatedPath(this._path)}`;
+        this.fire('title-change', {title});
+      });
+
+      const promises = [];
+
+      promises.push(this._getChangeDetail(this._changeNum));
+      promises.push(this._getFileContent(this._changeNum, this._path)
+          .then(fileContent => {
+            this._content = fileContent;
+            this._newContent = fileContent;
+          }));
+      return Promise.all(promises);
+    },
+
+    _getChangeDetail(changeNum) {
+      return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+        this._change = change;
+      });
+    },
+
+    _handlePathChanged(e) {
+      const path = e.detail;
+      if (path === this._path) { return Promise.resolve(); }
+      return this.$.restAPI.renameFileInChangeEdit(this._changeNum,
+          this._path, path).then(res => {
+            if (!res.ok) { return; }
+            this._viewEditInChangeView();
+          });
+    },
+
+    _viewEditInChangeView() {
+      Gerrit.Nav.navigateToChange(this._change, this.EDIT_NAME);
+    },
+
+    _getFileContent(changeNum, path) {
+      return this.$.restAPI.getFileInChangeEdit(changeNum, path);
+    },
+
+    _saveEdit() {
+      return this.$.restAPI.saveChangeEdit(this._changeNum, this._path,
+          this._newContent).then(res => {
+            if (!res.ok) { return; }
+            this._viewEditInChangeView();
+          });
+    },
+
+    _computeSaveDisabled(content, newContent) {
+      return content === newContent;
+    },
+
+    _handleCancelTap() {
+      // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
+      this._viewEditInChangeView();
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
new file mode 100644
index 0000000..e3e6474
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-editor-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-editor-view.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-editor-view></gr-editor-view>
+  </template>
+</test-fixture>
+
+<script>
+suite('gr-editor-view tests', () => {
+  let element;
+  let sandbox;
+  let savePathStub;
+  let saveFileStub;
+  let changeDetailStub;
+  let navigateStub;
+  const mockParams = {
+    changeNum: '42',
+    path: 'foo/bar.baz',
+  };
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+    });
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    savePathStub = sandbox.stub(element.$.restAPI, 'renameFileInChangeEdit');
+    saveFileStub = sandbox.stub(element.$.restAPI, 'saveChangeEdit');
+    changeDetailStub = sandbox.stub(element.$.restAPI, 'getDiffChangeDetail');
+    navigateStub = sandbox.stub(element, '_viewEditInChangeView');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  suite('_paramsChanged', () => {
+    test('incorrect view returns immediately', () => {
+      element._paramsChanged(
+          Object.assign({}, mockParams, {view: Gerrit.Nav.View.DIFF}));
+      assert.notOk(element._changeNum);
+    });
+
+    test('good params proceed', () => {
+      changeDetailStub.returns(Promise.resolve({}));
+      const fileStub = sandbox.stub(element, '_getFileContent')
+          .returns(Promise.resolve('text'));
+
+      const promises = element._paramsChanged(
+          Object.assign({}, mockParams, {view: Gerrit.Nav.View.EDIT}));
+
+      flushAsynchronousOperations();
+      assert.equal(element._changeNum, mockParams.changeNum);
+      assert.equal(element._path, mockParams.path);
+      assert.deepEqual(changeDetailStub.lastCall.args[0],
+          mockParams.changeNum);
+      assert.deepEqual(fileStub.lastCall.args,
+          [mockParams.changeNum, mockParams.path]);
+
+      return promises.then(() => {
+        assert.equal(element._content, 'text');
+        assert.equal(element._newContent, 'text');
+      });
+    });
+  });
+
+  test('edit file path', done => {
+    element._changeNum = mockParams.changeNum;
+    element._path = mockParams.path;
+    savePathStub.onFirstCall().returns(Promise.resolve({}));
+    savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
+
+    // Calling with the same path should not navigate.
+    element._handlePathChanged({detail: mockParams.path}).then(() => {
+      assert.isFalse(savePathStub.called);
+        // !ok response
+      element._handlePathChanged({detail: 'newPath'}).then(() => {
+        assert.isTrue(savePathStub.called);
+        assert.isFalse(navigateStub.called);
+        // ok response
+        element._handlePathChanged({detail: 'newPath'}).then(() => {
+          assert.isTrue(navigateStub.called);
+          done();
+        });
+      });
+    });
+  });
+
+  suite('edit file content', () => {
+    const originalText = 'file text';
+    const newText = 'file text changed';
+
+    setup(() => {
+      element._changeNum = mockParams.changeNum;
+      element._path = mockParams.path;
+      element._content = originalText;
+      element._newContent = originalText;
+      flushAsynchronousOperations();
+    });
+
+    test('initial load', () => {
+      assert.equal(element.$.file.value, originalText);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+    });
+
+    test('file modification and save, !ok response', done => {
+      const saveSpy = sandbox.spy(element, '_saveEdit');
+      saveFileStub.returns(Promise.resolve({ok: false}));
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.file.value, newText);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.save);
+      assert(saveSpy.called);
+      saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.deepEqual(saveFileStub.lastCall.args,
+            [mockParams.changeNum, mockParams.path, newText]);
+        assert.isFalse(navigateStub.called);
+        done();
+      });
+    });
+
+    test('file modification and save', done => {
+      const saveSpy = sandbox.spy(element, '_saveEdit');
+      saveFileStub.returns(Promise.resolve({ok: true}));
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.file.value, newText);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isTrue(navigateStub.called);
+        done();
+      });
+    });
+
+    test('file modification and cancel', () => {
+      const cancelSpy = sandbox.spy(element, '_handleCancelTap');
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.file.value, newText);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.cancel);
+      assert.isTrue(cancelSpy.called);
+      assert.isFalse(saveFileStub.called);
+      assert.isTrue(navigateStub.called);
+    });
+  });
+});
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index 6f8a4a1..eac4131 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -46,6 +46,7 @@
 <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="./edit/gr-editor-view/gr-editor-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">
@@ -151,11 +152,15 @@
             view-state="{{_viewState.changeView}}"
             back-page="[[_lastSearchPage]]"></gr-change-view>
       </template>
-      <template is="dom-if" if="[[_showDiffView]]" restamp="true">
-        <gr-diff-view
-            params="[[params]]"
-            change-view-state="{{_viewState.changeView}}"></gr-diff-view>
+      <template is="dom-if" if="[[_showEditorView]]" restamp="true">
+        <gr-editor-view
+            params="[[params]]"></gr-editor-view>
       </template>
+      <template is="dom-if" if="[[_showDiffView]]" restamp="true">
+          <gr-diff-view
+              params="[[params]]"
+              change-view-state="{{_viewState.changeView}}"></gr-diff-view>
+        </template>
       <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
         <gr-settings-view
             params="[[params]]"
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 4a38b85..8da8e16 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -56,6 +56,7 @@
       _showSettingsView: Boolean,
       _showAdminView: Boolean,
       _showCLAView: Boolean,
+      _showEditorView: Boolean,
       /** @type {?} */
       _viewState: Object,
       /** @type {?} */
@@ -139,6 +140,7 @@
       this.set('_showSettingsView', view === Gerrit.Nav.View.SETTINGS);
       this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN);
       this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS);
+      this.set('_showEditorView', view === Gerrit.Nav.View.EDIT);
       if (this.params.justRegistered) {
         this.$.registration.open();
       }
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
index e750c07..18eeb87 100644
--- 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
@@ -20,6 +20,17 @@
   }
 
   /**
+   * Add a callback to arbitrary event.
+   * The callback may return false to prevent event bubbling.
+   * @param {string} event Event name
+   * @param {function(Event):boolean} callback
+   * @return {function()} Unsubscribe function.
+   */
+  GrEventHelper.prototype.on = function(event, callback) {
+    return this._listen(this.element, callback, {event});
+  };
+
+  /**
    * Add a callback to element click or touch.
    * The callback may return false to prevent event bubbling.
    * @param {function(Event):boolean} callback
@@ -43,6 +54,7 @@
 
   GrEventHelper.prototype._listen = function(container, callback, opt_options) {
     const capture = opt_options && opt_options.capture;
+    const event = opt_options && opt_options.event || 'tap';
     const handler = e => {
       if (e.path.indexOf(this.element) !== -1) {
         let mayContinue = true;
@@ -58,9 +70,9 @@
         }
       }
     };
-    container.addEventListener('tap', handler, capture);
+    container.addEventListener(event, handler, capture);
     const unsubscribe = () =>
-      container.removeEventListener('tap', handler, capture);
+      container.removeEventListener(event, handler, capture);
     this._unsubscribers.push(unsubscribe);
     return unsubscribe;
   };
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
index 9d42851..43c42a9 100644
--- 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
@@ -92,5 +92,12 @@
       flushAsynchronousOperations();
       assert.isFalse(tapStub.called);
     });
+
+    test('on()', done => {
+      instance.on('foo', () => {
+        done();
+      });
+      element.fire('foo');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-project-api/gr-plugin-project-command.html b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-plugin-project-command.html
new file mode 100644
index 0000000..87d11ad
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-plugin-project-command.html
@@ -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.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../admin/gr-project-command/gr-project-command.html">
+
+<dom-module id="gr-plugin-project-command">
+  <template>
+    <gr-project-command title="[[title]]">
+    </gr-project-command>
+  </template>
+  <script>
+    Polymer({
+      is: 'gr-plugin-project-command',
+      properties: {
+        title: String,
+        projectName: String,
+        config: Object,
+      },
+    });
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api.html b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api.html
new file mode 100644
index 0000000..0106533
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-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-plugin-project-command.html">
+
+<dom-module id="gr-project-api">
+  <script src="gr-project-api.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api.js b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api.js
new file mode 100644
index 0000000..a173edd
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api.js
@@ -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.
+(function(window) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrProjectApi) { return; }
+
+  function GrProjectApi(plugin) {
+    this._hook = null;
+    this.plugin = plugin;
+  }
+
+  GrProjectApi.prototype._createHook = function(title) {
+    this._hook = this.plugin.hook('project-command').onAttached(element => {
+      const pluginCommand =
+            document.createElement('gr-plugin-project-command');
+      pluginCommand.title = title;
+      element.appendChild(pluginCommand);
+    });
+  };
+
+  GrProjectApi.prototype.createCommand = function(title, callback) {
+    if (this._hook) {
+      console.warn('Already set up.');
+      return this._hook;
+    }
+    this._createHook(title);
+    this._hook.onAttached(element => {
+      if (callback(element.projectName, element.config) === false) {
+        element.hidden = true;
+      }
+    });
+    return this;
+  };
+
+  GrProjectApi.prototype.onTap = function(callback) {
+    if (!this._hook) {
+      console.warn('Call createCommand first.');
+      return this;
+    }
+    this._hook.onAttached(element => {
+      this.plugin.eventHelper(element).on('command-tap', callback);
+    });
+    return this;
+  };
+
+  window.GrProjectApi = GrProjectApi;
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api_test.html b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api_test.html
new file mode 100644
index 0000000..b0719f5
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api_test.html
@@ -0,0 +1,80 @@
+<!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-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-project-api.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-endpoint-decorator name="project-command">
+    </gr-endpoint-decorator>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-project-api tests', () => {
+    let sandbox;
+    let projectApi;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
+      projectApi = plugin.project();
+    });
+
+    teardown(() => {
+      projectApi = null;
+      sandbox.restore();
+    });
+
+    test('exists', () => {
+      assert.isOk(projectApi);
+    });
+
+    test('works', done => {
+      const attachedStub = sandbox.stub();
+      const tapStub = sandbox.stub();
+      projectApi
+          .createCommand('foo', attachedStub)
+          .onTap(tapStub);
+      const element = fixture('basic');
+      flush(() => {
+        assert.isTrue(attachedStub.called);
+        const pluginCommand = element.$$('gr-plugin-project-command');
+        assert.isOk(pluginCommand);
+        const command = pluginCommand.$$('gr-project-command');
+        assert.isOk(command);
+        assert.equal(command.title, 'foo');
+        assert.isFalse(tapStub.called);
+        MockInteractions.tap(command.$$('gr-button'));
+        assert.isTrue(tapStub.called);
+        done();
+      });
+    });
+  });
+</script>
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
index c0449be..431ac0e 100644
--- 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
@@ -46,6 +46,7 @@
       },
       suggestions: {
         type: Array,
+        value: () => [],
         observer: '_resetCursorStops',
       },
       _suggestionEls: {
@@ -151,8 +152,12 @@
     },
 
     _resetCursorStops() {
-      Polymer.dom.flush();
-      this._suggestionEls = this.$.suggestions.querySelectorAll('li');
+      if (this.suggestions.length > 0) {
+        Polymer.dom.flush();
+        this._suggestionEls = this.$.suggestions.querySelectorAll('li');
+      } else {
+        this._suggestionEls = [];
+      }
     },
 
     _resetCursorIndex() {
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 c0b17af..7efd897 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -116,7 +116,10 @@
         color: #aaa;
       }
     </style>
-    <paper-button raised="[[!link]]" disabled="[[disabled]]">
+    <paper-button
+        raised="[[!link]]"
+        disabled="[[disabled]]"
+        tabindex="-1">
       <content></content>
       <i class="downArrow"></i>
     </paper-button>
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 551fd7f..911f49d 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -18,6 +18,7 @@
     is: 'gr-button',
 
     properties: {
+      tooltip: String,
       downArrow: {
         type: Boolean,
         reflectToAttribute: true,
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 b06b962..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
@@ -45,20 +45,23 @@
       footer {
         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-dropdown/gr-dropdown.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
index b821909..6d7df94 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
@@ -48,9 +48,6 @@
         width: 2em;
         vertical-align: middle;
       }
-      gr-button[link] {
-        padding: 0.5em;
-      }
       gr-button[link]:focus {
         outline: 5px auto -webkit-focus-ring-color;
       }
@@ -66,6 +63,9 @@
         display: block;
         padding: .85em 1em;
       }
+      li .itemAction {
+        @apply --gr-dropdown-item;
+      }
       li .itemAction.disabled {
         color: #ccc;
         cursor: default;
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 6eb7c8d..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
@@ -92,8 +92,8 @@
                 label="[[labelText]]"
                 value="{{_inputText}}"></paper-input>
             <div class="buttons">
-              <gr-button id="cancelBtn" on-tap="_cancel">cancel</gr-button>
-              <gr-button id="saveBtn" on-tap="_save">save</gr-button>
+              <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>
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 4133600..c5b0441 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
@@ -21,6 +21,7 @@
 <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-project-api/gr-project-api.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">
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 467e012..4e6b8cf 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
@@ -198,6 +198,10 @@
     return new GrThemeApi(this);
   };
 
+  Plugin.prototype.project = function() {
+    return new GrProjectApi(this);
+  };
+
   Plugin.prototype.attributeHelper = function(element) {
     return new GrAttributeHelper(element);
   };
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 5153fb0..837552e 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
@@ -765,6 +765,11 @@
         // Response may be an array of changes OR an array of arrays of
         // changes.
         if (opt_query instanceof Array) {
+          // Normalize the response to look like a multi-query response
+          // when there is only one query.
+          if (opt_query.length === 1) {
+            response = [response];
+          }
           for (const arr of response) {
             iterateOverChanges(arr);
           }
@@ -899,6 +904,17 @@
           patchRange.patchNum);
     },
 
+    /**
+     * @param {number|string} changeNum
+     * @param {number|string} patchNum
+     * @param {string} query
+     * @return {!Promise<!Object>}
+     */
+    queryChangeFiles(changeNum, patchNum, query) {
+      return this._getChangeURLAndFetch(changeNum,
+          `/files?q=${encodeURIComponent(query)}`, patchNum);
+    },
+
     getChangeFilesAsSpeciallySortedArray(changeNum, patchRange) {
       return this.getChangeFiles(changeNum, patchRange).then(
           this._normalizeChangeFilesResponse.bind(this));
@@ -1249,9 +1265,19 @@
           .then(response => this.getResponseObject(response));
     },
 
+    /**
+     * Gets a file in a change edit.
+     * @param {number|string} changeNum
+     * @param {string} path
+     */
     getFileInChangeEdit(changeNum, path) {
       const e = '/edit/' + encodeURIComponent(path);
-      return this.getChangeURLAndSend(changeNum, 'GET', null, e);
+      const headers = {Accept: 'application/json'};
+      return this.getChangeURLAndSend(changeNum, 'GET', null, e, null, null,
+          null, null, headers).then(res => {
+            if (!res.ok) { return res; }
+            return this.getResponseObject(res);
+          });
     },
 
     rebaseChangeEdit(changeNum) {
@@ -1279,7 +1305,8 @@
 
     saveChangeEdit(changeNum, path, contents) {
       const e = '/edit/' + encodeURIComponent(path);
-      return this.getChangeURLAndSend(changeNum, 'PUT', null, e, contents);
+      return this.getChangeURLAndSend(changeNum, 'PUT', null, e, contents, null,
+          null, 'text/plain');
     },
 
     // Deprecated, prefer to use putChangeCommitMessage instead.
@@ -1315,8 +1342,10 @@
      *    passed as null sometimes.
      * @param {?=} opt_ctx
      * @param {?string=} opt_contentType
+     * @param {Object=} opt_headers
      */
-    send(method, url, opt_body, opt_errFn, opt_ctx, opt_contentType) {
+    send(method, url, opt_body, opt_errFn, opt_ctx, opt_contentType,
+        opt_headers) {
       const options = {method};
       if (opt_body) {
         options.headers = new Headers();
@@ -1327,6 +1356,13 @@
         }
         options.body = opt_body;
       }
+      if (opt_headers) {
+        if (!options.headers) { options.headers = new Headers(); }
+        for (const header in opt_headers) {
+          if (!opt_headers.hasOwnProperty(header)) { continue; }
+          options.headers.set(header, opt_headers[header]);
+        }
+      }
       return this._auth.fetch(this.getBaseUrl() + url, options)
           .then(response => {
             if (!response.ok) {
@@ -1335,7 +1371,6 @@
               }
               this.fire('server-error', {response});
             }
-
             return response;
           }).catch(err => {
             this.fire('network-error', {error: err});
@@ -1845,16 +1880,17 @@
      * @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 {?function(?Response, string=)=} opt_errFn
      * @param {?=} opt_ctx
      * @param {?=} opt_contentType
+     * @param {Object=} opt_headers
      * @return {!Promise<!Object>}
      */
     getChangeURLAndSend(changeNum, method, patchNum, endpoint, opt_payload,
-        opt_errFn, opt_ctx, opt_contentType) {
+        opt_errFn, opt_ctx, opt_contentType, opt_headers) {
       return this._changeBaseURL(changeNum, patchNum).then(url => {
         return this.send(method, url + endpoint, opt_payload, opt_errFn,
-            opt_ctx, opt_contentType);
+            opt_ctx, opt_contentType, opt_headers);
       });
     },
 
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 eb4f418..aa7f9a0 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
@@ -701,6 +701,15 @@
       assert.equal(sendStub.lastCall.args[1], '/projects/x%2Fy');
     });
 
+    test('queryChangeFiles', () => {
+      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
+        assert.deepEqual(fetchStub.lastCall.args,
+            ['42', '/files?q=test%2Fpath.js', 'edit']);
+      });
+    });
+
     test('getProjects', () => {
       sandbox.stub(element, '_fetchSharedCacheURL');
       element.getProjects('test', 25);
diff --git a/polygerrit-ui/app/samples/project-command.html b/polygerrit-ui/app/samples/project-command.html
new file mode 100644
index 0000000..8131a02
--- /dev/null
+++ b/polygerrit-ui/app/samples/project-command.html
@@ -0,0 +1,42 @@
+<dom-module id="sample-project-command">
+  <script>
+    Gerrit.install(plugin => {
+      // High-level API
+      plugin.project()
+          .createCommand('Bork', (projectName, projectConfig) => {
+            if (projectName !== 'All-Projects') {
+              return false;
+            }
+          }).onTap(() => {
+            alert('Bork, bork!');
+          });
+
+      // Low-level API
+      plugin.registerCustomComponent(
+          'project-command', 'project-command-low');
+    });
+  </script>
+</dom-module>
+
+<!-- Low-level custom component for project command. -->
+<dom-module id="project-command-low">
+  <template>
+    <gr-project-command
+        title="Low-level bork"
+        on-command-tap="_handleCommandTap">
+    </gr-project-command>
+  </template>
+  <script>
+    Polymer({
+      is: 'project-command-low',
+      attached() {
+        console.log(this.projectName);
+        console.log(this.config);
+        this.hidden = this.projectName !== 'All-Projects';
+      },
+      _handleCommandTap() {
+        alert('(softly) bork, bork.');
+      },
+    });
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/template_test_srcs/template_test.js b/polygerrit-ui/app/template_test_srcs/template_test.js
index dffcaf9..401f0aa 100644
--- a/polygerrit-ui/app/template_test_srcs/template_test.js
+++ b/polygerrit-ui/app/template_test_srcs/template_test.js
@@ -22,7 +22,9 @@
   'GrDiffGroup',
   'GrDiffLine',
   'GrDomHooks',
+  'GrEditConstants',
   'GrEtagDecorator',
+  'GrFileListConstants',
   'GrGapiAuth',
   'GrGerritAuth',
   'GrLinkTextParser',
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 7080eb7..a690192 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -26,6 +26,7 @@
   const behaviorsPath = '../behaviors/';
 
   // Elements tests.
+  /* eslint-disable max-len */
   const elements = [
     // This seemed to be flakey when it was farther down the list. Keep at the
     // beginning.
@@ -44,6 +45,7 @@
     '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-command/gr-project-command_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',
@@ -102,10 +104,14 @@
     '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',
+    'edit/gr-edit-controls/gr-edit-controls_test.html',
+    'edit/gr-edit-file-controls/gr-edit-file-controls_test.html',
+    'edit/gr-editor-view/gr-editor-view_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-project-api/gr-project-api_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',
@@ -153,6 +159,7 @@
     'shared/gr-tooltip-content/gr-tooltip-content_test.html',
     'shared/gr-tooltip/gr-tooltip_test.html',
   ];
+  /* eslint-enable max-len */
   for (let file of elements) {
     file = elementsPath + file;
     testFiles.push(file);
@@ -160,6 +167,7 @@
   }
 
   // Behaviors tests.
+  /* eslint-disable max-len */
   const behaviors = [
     'async-foreach-behavior/async-foreach-behavior_test.html',
     'base-url-behavior/base-url-behavior_test.html',
@@ -173,6 +181,7 @@
     'gr-path-list-behavior/gr-path-list-behavior_test.html',
     'gr-tooltip-behavior/gr-tooltip-behavior_test.html',
   ];
+  /* eslint-enable max-len */
   for (let file of behaviors) {
     // Behaviors do not utilize the DOM, so no shadow DOM test is necessary.
     file = behaviorsPath + file;
diff --git a/gerrit-acceptance-framework/pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
similarity index 97%
rename from gerrit-acceptance-framework/pom.xml
rename to tools/maven/gerrit-acceptance-framework_pom.xml
index fc90095..a0f2e67 100644
--- a/gerrit-acceptance-framework/pom.xml
+++ b/tools/maven/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.15-rc1</version>
+  <version>2.16-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-extension-api/pom.xml b/tools/maven/gerrit-extension-api_pom.xml
similarity index 97%
rename from gerrit-extension-api/pom.xml
rename to tools/maven/gerrit-extension-api_pom.xml
index 4724776..a8ae2e6 100644
--- a/gerrit-extension-api/pom.xml
+++ b/tools/maven/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.15-rc1</version>
+  <version>2.16-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/gerrit-plugin-api/pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
similarity index 97%
rename from gerrit-plugin-api/pom.xml
rename to tools/maven/gerrit-plugin-api_pom.xml
index 9027450..84df44a 100644
--- a/gerrit-plugin-api/pom.xml
+++ b/tools/maven/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.15-rc1</version>
+  <version>2.16-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/tools/maven/gerrit-plugin-gwtui_pom.xml
similarity index 97%
rename from gerrit-plugin-gwtui/pom.xml
rename to tools/maven/gerrit-plugin-gwtui_pom.xml
index e0b23aa..cc9aafc 100644
--- a/gerrit-plugin-gwtui/pom.xml
+++ b/tools/maven/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.15-rc1</version>
+  <version>2.16-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-war/pom.xml b/tools/maven/gerrit-war_pom.xml
similarity index 97%
rename from gerrit-war/pom.xml
rename to tools/maven/gerrit-war_pom.xml
index aca36b7..c43c098 100644
--- a/gerrit-war/pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>2.15-rc1</version>
+  <version>2.16-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/maven/mvn.py b/tools/maven/mvn.py
index f7b5aa8..2e1c1a9 100755
--- a/tools/maven/mvn.py
+++ b/tools/maven/mvn.py
@@ -56,7 +56,7 @@
 for spec in args.s:
   artifact, packaging_type, src = spec.split(':')
   exe = cmd + [
-    '-DpomFile=%s' % path.join(root, '%s/pom.xml' % artifact),
+    '-DpomFile=%s' % path.join(root, 'tools', 'maven', '%s_pom.xml' % artifact),
     '-Dpackaging=%s' % packaging_type,
     '-Dfile=%s' % src,
   ]
diff --git a/tools/release-announcement-template.txt b/tools/release-announcement-template.txt
index 87f5d49..2702f57 100644
--- a/tools/release-announcement-template.txt
+++ b/tools/release-announcement-template.txt
@@ -7,7 +7,7 @@
 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 }}
+https://gerrit.googlesource.com/gerrit/+log/v{{ data.previous }}..v{{ data.version }}?no-merges
 {% endif %}
 Download:
 https://gerrit-releases.storage.googleapis.com/gerrit-{{ data.version }}.war
diff --git a/tools/version.py b/tools/version.py
index fed6d5d..72b0134 100755
--- a/tools/version.py
+++ b/tools/version.py
@@ -48,7 +48,7 @@
 for project in ['gerrit-acceptance-framework', 'gerrit-extension-api',
                 'gerrit-plugin-api', 'gerrit-plugin-gwtui',
                 'gerrit-war']:
-  pom = os.path.join(project, 'pom.xml')
+  pom = os.path.join('tools', 'maven', '%s_pom.xml' % project)
   replace_in_file(pom, src_pattern)
 
 src_pattern = re.compile(r'^(GERRIT_VERSION = ")([-.\w]+)(")$', re.MULTILINE)
diff --git a/version.bzl b/version.bzl
index 9f582b3..9a97721 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,8 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "2.15-rc1"
+GERRIT_VERSION = "2.16-SNAPSHOT"
+
+def check_version(x):
+    if native.bazel_version < x:
+        fail("\nERROR: Current Bazel version is {}, expected at least {}\n".format(native.bazel_version, x))